@lionhummer/constellation 0.1.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 ADDED
@@ -0,0 +1,124 @@
1
+ # Constellation TypeScript SDK
2
+
3
+ The SDK submits durable requests, observes events, responds to tool/context inputs, and records caller telemetry. Obvious owns prompts, conversation history, tool implementations and continuation decisions. Constellation connects to providers directly over HTTP.
4
+
5
+ The package builds JavaScript and type declarations in `dist` for npm. It has
6
+ not been published yet. The web test page imports its source through the
7
+ `@lionhummer/constellation` alias while this monorepo is under development.
8
+ External SDK calls use `/v1/*` with a bearer key. The first-party console sets
9
+ `apiPrefix: "/internal"` and supplies its session-aware fetch adapter; that
10
+ prefix is for the signed-in console, not third-party integrations.
11
+
12
+ ```ts
13
+ import { Constellation, uuid7 } from "@lionhummer/constellation";
14
+
15
+ const client = new Constellation({ baseUrl: "https://constellation.example", apiKey });
16
+ const request = await client.submit({
17
+ idempotencyKey: uuid7(), // Persist and reuse this key when retrying submission.
18
+ externalId: threadId, // Search/group identifier; no shared history.
19
+ workspaceId,
20
+ model: "obvious/fusion-1p0", // Physical model or virtual SKU.
21
+ kind: "run",
22
+ messages: [{ role: "user", text: "Echo hello using the tool." }],
23
+ tools: [{
24
+ name: "echo",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: { text: { type: "string" } },
28
+ required: ["text"],
29
+ additionalProperties: false,
30
+ },
31
+ }],
32
+ });
33
+
34
+ const result = await client.drive(request.id, {
35
+ consumerId: stableConsumerId, // UUIDv7; reuse when reconnecting this consumer.
36
+ tools: {
37
+ echo: async (args, { inputId, signal }) => {
38
+ // Use inputId as the idempotency key for external tool side effects.
39
+ signal?.throwIfAborted();
40
+ return args;
41
+ },
42
+ },
43
+ });
44
+ console.log(result.request.status, result.request.customerCost);
45
+ ```
46
+
47
+ For a single model call, use `kind: "generate"` without tools. Poll `inspect(id)` or consume `events(id, { after, signal })` to reconnect from a stored event cursor. Disconnecting an observer does not cancel execution; use `cancel(id)` explicitly. Waiting inputs survive restarts; claimed inputs remain assigned to their consumer. Tool handlers must honor cancellation and implement idempotency themselves.
48
+
49
+ Explicit span times support instrumentation outside Constellation:
50
+
51
+ ```ts
52
+ const span = await client.startSpan(request.id, "application.lookup", {
53
+ startedAt: lookupStartedAt,
54
+ attributes: { source: "obvious" },
55
+ });
56
+ await span.end({ endedAt: lookupEndedAt, status: "ok" });
57
+ await client.addLogs(request.id, [{
58
+ id: uuid7(), spanId: span.id, level: "info", message: "Lookup completed",
59
+ timestamp: lookupEndedAt, attributes: {},
60
+ }]);
61
+ ```
62
+
63
+ Keep execution keys on a trusted server unless intentionally scoped for a client. Provider credentials never belong in this SDK. See [the execution contract](../../docs/execution.md) for rule snapshots, pricing, waits, limits and recovery behavior.
64
+
65
+ Run SDK checks with `bun test packages/sdk` from the repository root. The web build also checks SDK types.
66
+
67
+ Resolve the exact execution input without creating a request or calling a provider:
68
+
69
+ ```ts
70
+ const preview = await client.resolve(input);
71
+ if (!preview.allowed) console.log(preview.rules);
72
+ const page = await client.list({ q: "external-id-or-request-id", limit: 25 });
73
+ ```
74
+
75
+ `resolve` does not verify provider credentials or future availability. The console
76
+ at `/test` is the SDK playground, with real LLM calls and local mock tools. Its
77
+ Resolve only option calls this same method. Inspection includes original `input`
78
+ and the current `effectiveInput` after application context refreshes.
79
+
80
+ For paginated monitoring, use `inspectSummary(id)` for request context, then
81
+ `records(id, "logs", { level: "error", q: "tool", offset: 0, limit: 25 })`.
82
+ Supported record kinds are `steps`, `attempts`, `inputs`, `spans`, and `logs`;
83
+ responses contain `items`, `total`, `offset`, and `limit`. Summary inspection
84
+ intentionally contains empty telemetry arrays; `inspect` retains its full behavior.
85
+
86
+ ## Client reference
87
+
88
+ | Method | Behavior |
89
+ | --- | --- |
90
+ | `submit(input, signal?)` | Submit a durable `generate` or `run` request and return `{ id }`. Persist `idempotencyKey` for retries. |
91
+ | `resolve(input, signal?)` | Return the current resolution and rule snapshot without dispatch. |
92
+ | `list(query?, signal?)` | Page requests; filter by `q`, `requestId`, `externalId`, `workspaceId`, `userId`, or `status`. |
93
+ | `inspect(id, signal?)` | Get full request context and telemetry. |
94
+ | `inspectSummary(id, signal?)` | Get context without loading historical telemetry arrays. |
95
+ | `records(id, kind, query?, signal?)` | Page one record kind with `id`, `q`, `level`, `offset`, and `limit`. |
96
+ | `events(id, { after?, signal? })` | Async iterator over live SSE with durable cursor replay. Persist `seq` to resume. |
97
+ | `drive(id, options)` | Claim and answer pending inputs until terminal; supports `tools`, `prepareStep`, `consumerId`, `toolTimeoutMs`, `onUpdate`, and `signal`. |
98
+ | `claim(id, inputId, claimId, signal?)` / `respond(id, inputId, response, signal?)` | Manually handle a waiting input. |
99
+ | `cancel(id, signal?)` | Explicitly cancel a durable request. |
100
+ | `addSpans(id, spans, signal?)` / `startSpan(id, name, options?, signal?)` | Upsert caller spans; `startSpan` returns an `end()` handle. |
101
+ | `addLogs(id, logs, signal?)` | Append caller logs. |
102
+
103
+ `RequestInput` requires `idempotencyKey`, `workspaceId`, `model`, `kind`, and
104
+ `messages`. It also accepts `externalId`, `userId`, `planId`, `instructions`,
105
+ `tools`, `parameters`, `maxSteps`, `timeoutMs`, `maxOutputTokens`,
106
+ `prepareStep` and `cache`. Set `cache.mode` to `automatic` for provider-native
107
+ prefix caching or `explicit` with up to four `points` for exact breakpoints.
108
+ Points target tools, instructions, or a message index and optional `partIndex`
109
+ within `contentParts`. Optional `affinityKey` and `isolationKey` are sent only
110
+ through fields configured on the selected provider. An isolation key excludes
111
+ providers without a mapping; explicit mode excludes providers without exact
112
+ marker support. The selected attempt records whether affinity was applied.
113
+ `kind: "generate"` is a single generation;
114
+ `kind: "run"` can pause for tools or context refreshes. The SDK exports
115
+ `uuid7`, typed request/inspection records, and `ConstellationError` with HTTP status,
116
+ error code, and message. All calls use the configured timeout (30 seconds by
117
+ default) and an optional abort signal.
118
+
119
+ This package covers execution requests. Model discovery (`GET /v1/models`,
120
+ `POST /v1/registry/resolve`), provider HTTP capture, the management APIs, and
121
+ operator APIs are available over HTTP; see the [HTTP API guide](../../docs/registry-api.md)
122
+ and the in-app Documentation page for those endpoint contracts. `events()` uses
123
+ SSE and reconnects from the durable cursor. Aborting `drive()` disconnects the consumer but does not
124
+ cancel the Constellation request.
@@ -0,0 +1,306 @@
1
+ /** Durable request client. No provider credentials or agent definitions live here. */
2
+ export type Json = null | boolean | number | string | Json[] | {
3
+ [key: string]: Json;
4
+ };
5
+ export type Tool = {
6
+ name: string;
7
+ description?: string;
8
+ inputSchema: Json;
9
+ };
10
+ export type ToolCall = {
11
+ id: string;
12
+ name: string;
13
+ arguments: Json;
14
+ };
15
+ export type Message = {
16
+ role: "system" | "user" | "assistant" | "tool";
17
+ text?: string;
18
+ /** Ordered text parts, used when a cache point must end within a message. */
19
+ contentParts?: string[];
20
+ nativeContent?: Json;
21
+ toolCalls?: ToolCall[];
22
+ toolResults?: {
23
+ id: string;
24
+ result: Json;
25
+ isError?: boolean;
26
+ }[];
27
+ };
28
+ export type RequestInput = {
29
+ idempotencyKey: string;
30
+ externalId?: string;
31
+ workspaceId: string;
32
+ userId?: string;
33
+ planId?: string;
34
+ model: string;
35
+ kind: "generate" | "run";
36
+ instructions?: string;
37
+ messages: Message[];
38
+ tools?: Tool[];
39
+ parameters?: Record<string, Json>;
40
+ maxSteps?: number;
41
+ timeoutMs?: number;
42
+ maxOutputTokens?: number;
43
+ prepareStep?: boolean;
44
+ cache?: {
45
+ mode: "automatic" | "explicit";
46
+ points?: ({
47
+ target: "tools" | "instructions";
48
+ } | {
49
+ target: "message";
50
+ index: number;
51
+ partIndex?: number;
52
+ })[];
53
+ affinityKey?: string;
54
+ isolationKey?: string;
55
+ };
56
+ };
57
+ export type Status = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled";
58
+ export type RequestRecord = {
59
+ id: string;
60
+ externalId: string | null;
61
+ workspaceId: string;
62
+ userId: string | null;
63
+ kind: "generate" | "run";
64
+ requestedModel: string;
65
+ virtualModel: boolean;
66
+ planId: string | null;
67
+ catalogRevision: number;
68
+ status: Status;
69
+ reason: string | null;
70
+ error: Json;
71
+ output: {
72
+ text: string;
73
+ reasoning: string;
74
+ toolCalls: ToolCall[];
75
+ } | null;
76
+ stepCount: number;
77
+ customerCost: string | null;
78
+ supplierCost: string | null;
79
+ resolution: Json;
80
+ rules: Json;
81
+ createdAt: string;
82
+ endedAt: string | null;
83
+ deadline: string;
84
+ };
85
+ export type Step = {
86
+ id: string;
87
+ requestId: string;
88
+ idx: number;
89
+ input: Json;
90
+ inputHash: string;
91
+ status: string;
92
+ output: Json;
93
+ usage: Json;
94
+ customerPrice: Json;
95
+ customerCost: string | null;
96
+ supplierCost: string | null;
97
+ finishReason: string | null;
98
+ startedAt: string;
99
+ endedAt: string | null;
100
+ };
101
+ export type Attempt = {
102
+ id: string;
103
+ requestId: string;
104
+ stepId: string;
105
+ idx: number;
106
+ requestedModel: string;
107
+ actualModel: string;
108
+ providerId: string;
109
+ offeringId: string;
110
+ providerModel: string;
111
+ connectionKind: string;
112
+ endpoint: string;
113
+ fallback: boolean;
114
+ status: string;
115
+ committed: boolean;
116
+ supplierPrice: Json;
117
+ customerPrice: Json;
118
+ parameters: Json;
119
+ usage: Json;
120
+ supplierCost: string | null;
121
+ customerCost: string | null;
122
+ error: Json;
123
+ finishReason: string | null;
124
+ httpStatus: number | null;
125
+ ttfbMs: number | null;
126
+ ttftMs: number | null;
127
+ startedAt: string;
128
+ endedAt: string | null;
129
+ };
130
+ export type PendingInput = {
131
+ id: string;
132
+ requestId: string;
133
+ stepId: string | null;
134
+ kind: "tool" | "prepare_step" | "user_input";
135
+ payload: {
136
+ providerCallId?: string;
137
+ name?: string;
138
+ arguments?: Json;
139
+ validationError?: string | null;
140
+ };
141
+ claimId: string | null;
142
+ response: Json;
143
+ resolvedAt: string | null;
144
+ };
145
+ export type Span = {
146
+ id: string;
147
+ stepId?: string | null;
148
+ attemptId?: string | null;
149
+ parentSpanId?: string | null;
150
+ name: string;
151
+ startedAt: string;
152
+ endedAt?: string | null;
153
+ status: "running" | "ok" | "error" | "cancelled";
154
+ attributes?: Record<string, Json>;
155
+ };
156
+ export type Log = {
157
+ id: string;
158
+ stepId?: string | null;
159
+ spanId?: string | null;
160
+ level: "debug" | "info" | "warn" | "error";
161
+ message: string;
162
+ timestamp: string;
163
+ attributes?: Record<string, Json>;
164
+ };
165
+ export type Inspection = {
166
+ usage?: Json;
167
+ input?: Json;
168
+ effectiveInput?: Json;
169
+ request: RequestRecord;
170
+ steps: Step[];
171
+ attempts: Attempt[];
172
+ inputs: PendingInput[];
173
+ spans: Span[];
174
+ logs: Log[];
175
+ };
176
+ export type RecordTypes = {
177
+ steps: Step;
178
+ attempts: Attempt;
179
+ inputs: PendingInput;
180
+ spans: Span;
181
+ logs: Log;
182
+ };
183
+ export type InputResponse = {
184
+ claimId?: string;
185
+ result?: Json;
186
+ isError?: boolean;
187
+ instructions?: string;
188
+ tools?: Tool[];
189
+ action?: "continue" | "refresh_context" | "wait_for_user" | "complete";
190
+ };
191
+ export type RequestEvent = {
192
+ seq: number;
193
+ type: string;
194
+ data: Record<string, Json>;
195
+ timestamp: string;
196
+ };
197
+ export declare class ConstellationError extends Error {
198
+ status: number;
199
+ code: string;
200
+ constructor(status: number, code: string, message: string);
201
+ }
202
+ export declare function uuid7(): string;
203
+ export declare class Constellation {
204
+ private options;
205
+ private fetcher;
206
+ constructor(options: {
207
+ baseUrl: string;
208
+ apiKey?: string;
209
+ apiPrefix?: "/v1" | "/internal";
210
+ fetch?: typeof fetch;
211
+ timeoutMs?: number;
212
+ });
213
+ private url;
214
+ private call;
215
+ submit(input: RequestInput, signal?: AbortSignal): Promise<{
216
+ id: string;
217
+ }>;
218
+ inspect(id: string, signal?: AbortSignal): Promise<Inspection>;
219
+ /** Request context without loading historical telemetry. Fetch records by page. */
220
+ inspectSummary(id: string, signal?: AbortSignal): Promise<Inspection>;
221
+ records<K extends keyof RecordTypes>(id: string, kind: K, query?: {
222
+ id?: string;
223
+ q?: string;
224
+ level?: Log["level"];
225
+ offset?: number;
226
+ limit?: number;
227
+ }, signal?: AbortSignal): Promise<{
228
+ items: RecordTypes[K][];
229
+ total: number;
230
+ offset: number;
231
+ limit: number;
232
+ }>;
233
+ resolve(input: RequestInput, signal?: AbortSignal): Promise<{
234
+ revision: number;
235
+ resolution: Record<string, Json>;
236
+ rules: Record<string, Json>;
237
+ allowed: boolean;
238
+ dispatched: false;
239
+ }>;
240
+ list(query?: {
241
+ q?: string;
242
+ requestId?: string;
243
+ requestIdNot?: string;
244
+ externalId?: string;
245
+ externalIdNot?: string;
246
+ externalIdContains?: string;
247
+ externalIdNotContains?: string;
248
+ workspaceId?: string;
249
+ workspaceIdNot?: string;
250
+ userId?: string;
251
+ userIdNot?: string;
252
+ status?: Status;
253
+ statusNot?: Status;
254
+ requestedModel?: string;
255
+ requestedModelNot?: string;
256
+ requestedModelContains?: string;
257
+ requestedModelNotContains?: string;
258
+ limit?: number;
259
+ offset?: number;
260
+ }, signal?: AbortSignal): Promise<{
261
+ items: RequestRecord[];
262
+ total: number;
263
+ offset: number;
264
+ limit: number;
265
+ }>;
266
+ cancel(id: string, signal?: AbortSignal): Promise<void>;
267
+ claim(id: string, inputId: string, claimId: string, signal?: AbortSignal): Promise<void>;
268
+ respond(id: string, inputId: string, response: InputResponse, signal?: AbortSignal): Promise<void>;
269
+ addSpans(id: string, spans: Span[], signal?: AbortSignal): Promise<void>;
270
+ addLogs(id: string, logs: Log[], signal?: AbortSignal): Promise<void>;
271
+ startSpan(id: string, name: string, options?: {
272
+ stepId?: string;
273
+ attemptId?: string;
274
+ parentSpanId?: string;
275
+ startedAt?: string;
276
+ attributes?: Record<string, Json>;
277
+ }, signal?: AbortSignal): Promise<{
278
+ id: string;
279
+ end: (options?: {
280
+ endedAt?: string;
281
+ status?: "ok" | "error" | "cancelled";
282
+ attributes?: Record<string, Json>;
283
+ }, signal?: AbortSignal) => Promise<void>;
284
+ }>;
285
+ /** Live SSE delivery with durable cursor replay after disconnects. */
286
+ events(id: string, options?: {
287
+ after?: number;
288
+ signal?: AbortSignal;
289
+ }): AsyncGenerator<RequestEvent>;
290
+ /** Disconnecting this consumer leaves unresolved inputs durably waiting in Constellation. */
291
+ drive(id: string, options?: {
292
+ tools?: Record<string, (args: Json, context: {
293
+ inputId: string;
294
+ requestId: string;
295
+ signal?: AbortSignal;
296
+ }) => Promise<Json>>;
297
+ prepareStep?: (state: Inspection, context: {
298
+ inputId: string;
299
+ signal: AbortSignal;
300
+ }) => Promise<Pick<InputResponse, "instructions" | "tools" | "action">>;
301
+ signal?: AbortSignal;
302
+ consumerId?: string;
303
+ toolTimeoutMs?: number;
304
+ onUpdate?: (state: Inspection) => void;
305
+ }): Promise<Inspection>;
306
+ }
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ export class ConstellationError extends Error {
2
+ status;
3
+ code;
4
+ constructor(status, code, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.code = code;
8
+ this.name = "ConstellationError";
9
+ }
10
+ }
11
+ export function uuid7() {
12
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
13
+ let ms = Date.now();
14
+ for (let i = 5; i >= 0; i--) {
15
+ bytes[i] = ms % 256;
16
+ ms = Math.floor(ms / 256);
17
+ }
18
+ bytes[6] = 0x70 | (bytes[6] & 0x0f);
19
+ bytes[8] = 0x80 | (bytes[8] & 0x3f);
20
+ const h = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
22
+ }
23
+ const terminal = (s) => ["completed", "failed", "cancelled"].includes(s);
24
+ async function pause(ms, signal) {
25
+ signal?.throwIfAborted();
26
+ await new Promise((resolve, reject) => {
27
+ const abort = () => {
28
+ clearTimeout(timer);
29
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
30
+ };
31
+ const timer = setTimeout(() => {
32
+ signal?.removeEventListener("abort", abort);
33
+ resolve();
34
+ }, ms);
35
+ signal?.addEventListener("abort", abort, { once: true });
36
+ });
37
+ }
38
+ async function bounded(promise, signal) {
39
+ signal.throwIfAborted();
40
+ return new Promise((resolve, reject) => {
41
+ const abort = () => reject(signal.reason);
42
+ signal.addEventListener("abort", abort, { once: true });
43
+ promise
44
+ .then(resolve, reject)
45
+ .finally(() => signal.removeEventListener("abort", abort));
46
+ });
47
+ }
48
+ export class Constellation {
49
+ options;
50
+ fetcher;
51
+ constructor(options) {
52
+ this.options = options;
53
+ this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
54
+ }
55
+ url(path) {
56
+ return `${this.options.baseUrl.replace(/\/$/, "")}${path.replace(/^\/v1(?=\/)/, this.options.apiPrefix ?? "/v1")}`;
57
+ }
58
+ async call(path, method = "GET", body, signal) {
59
+ const response = await this.fetcher(this.url(path), {
60
+ method,
61
+ headers: {
62
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
63
+ ...(this.options.apiKey
64
+ ? { Authorization: `Bearer ${this.options.apiKey}` }
65
+ : {}),
66
+ },
67
+ body: body === undefined ? undefined : JSON.stringify(body),
68
+ signal: signal
69
+ ? AbortSignal.any([
70
+ signal,
71
+ AbortSignal.timeout(this.options.timeoutMs ?? 30000),
72
+ ])
73
+ : AbortSignal.timeout(this.options.timeoutMs ?? 30000),
74
+ });
75
+ if (!response.ok) {
76
+ const body = await response.json().catch(() => ({}));
77
+ throw new ConstellationError(response.status, body.error?.code ?? "http_error", body.error?.message ?? `Constellation returned ${response.status}`);
78
+ }
79
+ return response.status === 204
80
+ ? undefined
81
+ : response.json();
82
+ }
83
+ submit(input, signal) {
84
+ return this.call("/v1/requests", "POST", input, signal);
85
+ }
86
+ inspect(id, signal) {
87
+ return this.call(`/v1/requests/${encodeURIComponent(id)}`, "GET", undefined, signal);
88
+ }
89
+ /** Request context without loading historical telemetry. Fetch records by page. */
90
+ inspectSummary(id, signal) {
91
+ return this.call(`/v1/requests/${encodeURIComponent(id)}?summary=true`, "GET", undefined, signal);
92
+ }
93
+ records(id, kind, query = {}, signal) {
94
+ const params = new URLSearchParams();
95
+ for (const [key, value] of Object.entries(query))
96
+ if (value !== undefined)
97
+ params.set(key, String(value));
98
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/records/${kind}?${params}`, "GET", undefined, signal);
99
+ }
100
+ resolve(input, signal) {
101
+ return this.call("/v1/requests/resolve", "POST", input, signal);
102
+ }
103
+ list(query = {}, signal) {
104
+ const params = new URLSearchParams();
105
+ for (const [k, v] of Object.entries(query))
106
+ if (v !== undefined)
107
+ params.set(k, String(v));
108
+ return this.call(`/v1/requests?${params}`, "GET", undefined, signal);
109
+ }
110
+ cancel(id, signal) {
111
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/cancel`, "POST", {}, signal);
112
+ }
113
+ claim(id, inputId, claimId, signal) {
114
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}/claim`, "POST", { claimId }, signal);
115
+ }
116
+ respond(id, inputId, response, signal) {
117
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}`, "POST", response, signal);
118
+ }
119
+ addSpans(id, spans, signal) {
120
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/spans`, "POST", spans, signal);
121
+ }
122
+ addLogs(id, logs, signal) {
123
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/logs`, "POST", logs, signal);
124
+ }
125
+ async startSpan(id, name, options = {}, signal) {
126
+ const span = {
127
+ id: uuid7(),
128
+ name,
129
+ startedAt: options.startedAt ?? new Date().toISOString(),
130
+ status: "running",
131
+ ...options,
132
+ };
133
+ await this.addSpans(id, [span], signal);
134
+ let closed;
135
+ return {
136
+ id: span.id,
137
+ end: (options = {}, signal) => {
138
+ closed = {
139
+ ...span,
140
+ endedAt: options.endedAt ?? closed?.endedAt ?? new Date().toISOString(),
141
+ status: options.status ?? closed?.status ?? "ok",
142
+ attributes: options.attributes ?? closed?.attributes ?? span.attributes,
143
+ };
144
+ return this.addSpans(id, [closed], signal);
145
+ },
146
+ };
147
+ }
148
+ /** Live SSE delivery with durable cursor replay after disconnects. */
149
+ async *events(id, options = {}) {
150
+ let after = options.after ?? 0;
151
+ while (true) {
152
+ options.signal?.throwIfAborted();
153
+ const connecting = new AbortController();
154
+ const connectTimeout = setTimeout(() => connecting.abort(), this.options.timeoutMs ?? 30000);
155
+ const response = await Promise.resolve().then(() => this.fetcher(this.url(`/v1/requests/${encodeURIComponent(id)}/stream?after=${after}`), {
156
+ headers: {
157
+ Accept: "text/event-stream",
158
+ ...(this.options.apiKey ? { Authorization: `Bearer ${this.options.apiKey}` } : {}),
159
+ },
160
+ signal: options.signal ? AbortSignal.any([options.signal, connecting.signal]) : connecting.signal,
161
+ })).catch(async () => {
162
+ clearTimeout(connectTimeout);
163
+ options.signal?.throwIfAborted();
164
+ await pause(500, options.signal);
165
+ return null;
166
+ });
167
+ clearTimeout(connectTimeout);
168
+ if (!response)
169
+ continue;
170
+ if (!response.ok || !response.body) {
171
+ throw new ConstellationError(response.status, "stream_error", `Event stream returned ${response.status}`);
172
+ }
173
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
174
+ let buffer = "";
175
+ try {
176
+ while (true) {
177
+ const { value, done } = await reader.read();
178
+ if (done)
179
+ break;
180
+ buffer += value.replace(/\r\n/g, "\n");
181
+ let boundary;
182
+ while ((boundary = buffer.indexOf("\n\n")) >= 0) {
183
+ const frame = buffer.slice(0, boundary);
184
+ buffer = buffer.slice(boundary + 2);
185
+ const data = frame.split("\n").filter((line) => line.startsWith("data:"))
186
+ .map((line) => line.slice(5).trimStart()).join("\n");
187
+ if (!data)
188
+ continue;
189
+ const event = JSON.parse(data);
190
+ if (event.seq <= after)
191
+ continue;
192
+ after = event.seq;
193
+ yield event;
194
+ if (["request.completed", "request.failed", "request.cancelled"].includes(event.type))
195
+ return;
196
+ }
197
+ }
198
+ }
199
+ catch {
200
+ options.signal?.throwIfAborted();
201
+ // A broken live connection resumes from the last delivered sequence.
202
+ }
203
+ finally {
204
+ await reader.cancel().catch(() => { });
205
+ }
206
+ const inspection = await this.inspect(id, options.signal);
207
+ if (terminal(inspection.request.status)) {
208
+ const tail = await this.call(`/v1/requests/${encodeURIComponent(id)}/events?after=${after}`, "GET", undefined, options.signal);
209
+ for (const event of tail.items) {
210
+ if (event.seq <= after)
211
+ continue;
212
+ after = event.seq;
213
+ yield event;
214
+ }
215
+ if (tail.items.length < 500)
216
+ return;
217
+ continue;
218
+ }
219
+ await pause(500, options.signal);
220
+ }
221
+ }
222
+ /** Disconnecting this consumer leaves unresolved inputs durably waiting in Constellation. */
223
+ async drive(id, options = {}) {
224
+ const consumerId = options.consumerId ?? uuid7();
225
+ while (true) {
226
+ const state = await this.inspect(id, options.signal);
227
+ options.onUpdate?.(state);
228
+ if (terminal(state.request.status))
229
+ return state;
230
+ for (const pending of state.inputs.filter((i) => !i.resolvedAt)) {
231
+ if (pending.claimId && pending.claimId !== consumerId)
232
+ continue;
233
+ if (pending.kind === "prepare_step" && options.prepareStep) {
234
+ try {
235
+ await this.claim(id, pending.id, consumerId, options.signal);
236
+ }
237
+ catch (error) {
238
+ if (error instanceof ConstellationError && error.status === 409)
239
+ continue;
240
+ throw error;
241
+ }
242
+ let closed = false;
243
+ const preparation = await this.startSpan(id, "caller.prepare_step", { attributes: { inputId: pending.id } }, options.signal);
244
+ try {
245
+ const signal = options.signal
246
+ ? AbortSignal.any([
247
+ options.signal,
248
+ AbortSignal.timeout(options.toolTimeoutMs ?? 30000),
249
+ ])
250
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
251
+ const response = await bounded(options.prepareStep(state, { inputId: pending.id, signal }), signal);
252
+ await preparation.end({}, options.signal);
253
+ closed = true;
254
+ await this.respond(id, pending.id, { ...response, claimId: consumerId }, options.signal);
255
+ }
256
+ catch (error) {
257
+ if (!options.signal?.aborted && !closed)
258
+ await preparation
259
+ .end({ status: "error" }, options.signal)
260
+ .catch(() => { });
261
+ throw error;
262
+ }
263
+ continue;
264
+ }
265
+ if (pending.kind !== "tool")
266
+ continue;
267
+ const tool = options.tools?.[pending.payload.name ?? ""];
268
+ if (!tool && !pending.payload.validationError)
269
+ continue;
270
+ try {
271
+ await this.claim(id, pending.id, consumerId, options.signal);
272
+ }
273
+ catch (error) {
274
+ if (error instanceof ConstellationError && error.status === 409)
275
+ continue;
276
+ throw error;
277
+ }
278
+ const span = await this.startSpan(id, `tool.${pending.payload.name}`, {
279
+ stepId: pending.stepId ?? undefined,
280
+ attributes: { inputId: pending.id },
281
+ }, options.signal);
282
+ let result;
283
+ let isError = false;
284
+ try {
285
+ if (pending.payload.validationError)
286
+ throw new Error(pending.payload.validationError);
287
+ if (!tool)
288
+ throw new Error("Tool unavailable");
289
+ const toolSignal = options.signal
290
+ ? AbortSignal.any([
291
+ options.signal,
292
+ AbortSignal.timeout(options.toolTimeoutMs ?? 30000),
293
+ ])
294
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
295
+ result = await bounded(tool(pending.payload.arguments ?? {}, {
296
+ inputId: pending.id,
297
+ requestId: id,
298
+ signal: toolSignal,
299
+ }), toolSignal);
300
+ }
301
+ catch (error) {
302
+ options.signal?.throwIfAborted();
303
+ isError = true;
304
+ result = {
305
+ error: error instanceof Error ? error.message : "Tool failed",
306
+ };
307
+ }
308
+ await span.end({ status: isError ? "error" : "ok" }, options.signal);
309
+ await this.respond(id, pending.id, { result, isError, claimId: consumerId }, options.signal);
310
+ }
311
+ await pause(500, options.signal);
312
+ }
313
+ }
314
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@lionhummer/constellation",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FlatFilers/constellation.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "files": ["dist", "README.md"],
12
+ "publishConfig": { "access": "public" },
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "prepack": "bun run build",
24
+ "test": "bun test",
25
+ "typecheck": "tsc --noEmit --strict --target ES2022 --module ESNext --moduleResolution Bundler --lib ES2022,DOM src/index.ts"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5.9.0"
29
+ }
30
+ }