@lionhummer/constellation 0.1.1 → 0.1.3

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
@@ -1,125 +1,172 @@
1
1
  # Constellation TypeScript SDK
2
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.
3
+ `@lionhummer/constellation` is a server-side client for durable model execution. A request keeps running after `requests.create()` returns its ID. Constellation owns admission, routing, provider calls, waits and the execution trace. Your application supplies conversation state, tools and any policy or billing decisions.
4
4
 
5
- Install the published package with `npm install @lionhummer/constellation`.
6
- The package includes JavaScript and type declarations in `dist`. The web test
7
- page imports its source through the `@lionhummer/constellation` alias while
8
- this monorepo is under development.
9
- External SDK calls use `/v1/*` with a bearer key. The first-party console sets
10
- `apiPrefix: "/internal"` and supplies its session-aware fetch adapter; that
11
- prefix is for the signed-in console, not third-party integrations.
5
+ ```sh
6
+ npm install @lionhummer/constellation
7
+ ```
12
8
 
13
9
  ```ts
14
- import { Constellation, uuid7 } from "@lionhummer/constellation";
15
-
16
- const client = new Constellation({ baseUrl: "https://constellation.example", apiKey });
17
- const request = await client.submit({
18
- idempotencyKey: uuid7(), // Persist and reuse this key when retrying submission.
19
- externalId: threadId, // Search/group identifier; no shared history.
20
- workspaceId,
21
- model: "obvious/fusion-1p0", // Physical model or virtual SKU.
22
- kind: "run",
23
- messages: [{ role: "user", text: "Echo hello using the tool." }],
24
- tools: [{
25
- name: "echo",
26
- inputSchema: {
27
- type: "object",
28
- properties: { text: { type: "string" } },
29
- required: ["text"],
30
- additionalProperties: false,
31
- },
32
- }],
10
+ import { Constellation, uuid7, type InputHandlers } from "@lionhummer/constellation";
11
+
12
+ const client = new Constellation({
13
+ baseUrl: "https://your-constellation-host.example",
14
+ apiKey: process.env.CONSTELLATION_EXECUTION_KEY!,
33
15
  });
16
+ ```
17
+
18
+ Keep keys on a trusted server. One key may have execution, management and telemetry permissions; the method namespaces below map to those API permissions. An `AbortSignal` stops the current SDK operation, not the durable request. Use `requests.cancel(id)` to cancel execution.
19
+
20
+ ## Choose an execution kind
21
+
22
+ | Kind | Model steps | Caller participation |
23
+ | --- | --- | --- |
24
+ | `generate` | Exactly one. Tools are not accepted. | Observe the result. |
25
+ | `run` | Continues through model steps and caller tools. | Implement tools; there is no review gate before tool dispatch. |
26
+ | `agent` | First step executes from the complete submitted input; later steps use fresh complete inputs. | Review model output and usage before tools, review after tools, and decide whether to continue. |
27
+
28
+ All three use `client.requests.create(input)` and return `{ id }`. Persist the UUIDv7 `idempotencyKey` before submitting. If the POST outcome is uncertain, retry with that same key **and identical input**. `externalId` groups separate requests and can hint at prior-provider affinity; it does not retrieve conversation history.
29
+
30
+ ### One-step generation
31
+
32
+ ```ts
33
+ const { id } = await client.requests.create({
34
+ kind: "generate",
35
+ idempotencyKey: uuid7(),
36
+ workspaceId: "workspace-123",
37
+ model: "catalog/example-model",
38
+ messages: [{ role: "user", text: "Explain this result." }],
39
+ });
40
+
41
+ for await (const event of client.requests.streamEvents(id)) {
42
+ // Store event.seq to resume after a disconnect.
43
+ if (["request.completed", "request.failed", "request.cancelled"].includes(event.type)) break;
44
+ }
45
+ const final = await client.requests.get(id);
46
+ ```
47
+
48
+ `requests.preview(input)` applies access, pricing and routing rules without creating or dispatching a request. `models.list({ workspaceId })` discovers accessible models; `models.resolve({ workspaceId, targetId })` previews one model's candidates.
49
+
50
+ ### Tool-capable run
51
+
52
+ Use `run` when tools should execute without an application review gate after each model response. Each tool call becomes a durable caller input. The same handlers work with webhook delivery and explicit polling:
34
53
 
35
- const result = await client.drive(request.id, {
36
- consumerId: stableConsumerId, // UUIDv7; reuse when reconnecting this consumer.
54
+ ```ts
55
+ const handlers = {
56
+ consumerId: "stable-worker-id",
37
57
  tools: {
38
- echo: async (args, { inputId, signal }) => {
39
- // Use inputId as the idempotency key for external tool side effects.
40
- signal?.throwIfAborted();
41
- return args;
58
+ lookup: async (args, { inputId, signal }) => {
59
+ // Deduplicate external effects by inputId: callbacks can be delivered again.
60
+ return lookupOnce(args, inputId, signal);
42
61
  },
43
62
  },
44
- });
45
- console.log(result.request.status, result.request.customerCost);
63
+ } satisfies InputHandlers;
64
+
65
+ // From one verified input webhook:
66
+ await client.inputs.handle(requestId, inputId, handlers);
67
+
68
+ // Or run a polling consumer until terminal:
69
+ await client.inputs.poll(requestId, handlers);
46
70
  ```
47
71
 
48
- 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.
72
+ `inputs.handle()` processes only the named input and returns. `inputs.poll()` repeatedly inspects and handles inputs until the request is terminal. Neither silently switches delivery modes. For custom consumers, `inputs.claim()` and `inputs.respond()` expose the underlying claim and response operations.
73
+
74
+ ### Caller-controlled agent
49
75
 
50
- Explicit span times support instrumentation outside Constellation:
76
+ An agent submission contains the **complete first step**. Do not submit a placeholder message expecting an initial preparation callback. The SDK sets the server's `prepareStep` protocol flag for `agent` requests.
51
77
 
52
78
  ```ts
53
- const span = await client.startSpan(request.id, "application.lookup", {
54
- startedAt: lookupStartedAt,
55
- attributes: { source: "obvious" },
79
+ const { id } = await client.requests.create({
80
+ kind: "agent",
81
+ idempotencyKey: uuid7(),
82
+ workspaceId: "workspace-123",
83
+ externalId: "conversation-456",
84
+ model: "catalog/example-model",
85
+ instructions: "Work carefully and use tools when needed.",
86
+ messages: fullFirstStepHistory,
87
+ tools: toolDefinitions,
88
+ parameters: {},
89
+ maxOutputTokens: 4096,
56
90
  });
57
- await span.end({ endedAt: lookupEndedAt, status: "ok" });
58
- await client.addLogs(request.id, [{
59
- id: uuid7(), spanId: span.id, level: "info", message: "Lookup completed",
60
- timestamp: lookupEndedAt, attributes: {},
61
- }]);
62
91
  ```
63
92
 
64
- 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.
93
+ After each model step, Constellation issues `caller_review` with output, usage, customer cost, supplier cost and pending tools. Your application can settle usage and decide whether to allow tools. After tools, another review can finish or request the next step. Before a later model step, `prepare_step` asks for the **whole next input**: model, instructions, messages, tools, parameters, output limit and cache policy.
94
+
95
+ ```ts
96
+ const handlers = {
97
+ consumerId: "stable-worker-id",
98
+ callerReview: async (review, state, { inputId }) => ({
99
+ action: await settleAndDecideOnce(review, state, inputId),
100
+ }),
101
+ tools: {
102
+ lookup: async (args, { inputId, signal }) => lookupOnce(args, inputId, signal),
103
+ },
104
+ prepareStep: async (state, { signal, parentSpanId }) => ({
105
+ stepInput: await buildCompleteNextStep(state, { signal, parentSpanId }),
106
+ }),
107
+ } satisfies InputHandlers;
108
+ ```
109
+
110
+ Deduplicate billing and tool side effects by input ID. The SDK claims an input, renews an agent claim while its handler runs, adds parent spans for review/preparation/tool callbacks, and responds. If handling fails transiently, return a non-2xx webhook response so delivery retries. A terminal request rejects new caller spans and logs.
111
+
112
+ ## Webhooks and events
113
+
114
+ Configure the execution key's HTTPS `inputWebhook` URL and random token when creating the key, or later with `management.keys.setInputWebhook()`. The SDK can verify the token and parse the three callback shapes:
115
+
116
+ ```ts
117
+ const event = await client.webhooks.verify(request, process.env.CONSTELLATION_WEBHOOK_TOKEN!);
118
+ if ("inputId" in event) {
119
+ await client.inputs.handle(event.requestId, event.inputId, handlers);
120
+ } else if (event.kind === "request.model_events") {
121
+ await storeDeltaBatchOnce(event.requestId, event.afterSeq, event.events);
122
+ } else {
123
+ const state = await client.requests.getSummary(event.requestId);
124
+ await finalizeOnce(event.requestId, event.eventSeq, state);
125
+ }
126
+ // Return 2xx after your input response or other effects are durable.
127
+ ```
128
+
129
+ Input callbacks include `{requestId,inputId,kind,payload}` and `x-constellation-input-id`. Model-event callbacks contain `{requestId,kind:"request.model_events",afterSeq,events}`. Terminal callbacks contain `{requestId,eventSeq,kind}`. All carry `x-constellation-webhook-token`. Delivery is at least once. Model deltas may arrive late; use the final inspection as authority after terminal.
65
130
 
66
- Run SDK checks with `bun test packages/sdk` from the repository root. The web build also checks SDK types.
131
+ `requests.streamEvents(id, { after, signal })` is SSE with durable sequence replay. `requests.listEvents(id, after)` reads a bounded event page. SSE observes output; it does not process caller inputs. A dropped stream reconnects by cursor and does not cancel the request.
67
132
 
68
- Resolve the exact execution input without creating a request or calling a provider:
133
+ ## Inspect and instrument
69
134
 
70
135
  ```ts
71
- const preview = await client.resolve(input);
72
- if (!preview.allowed) console.log(preview.rules);
73
- const page = await client.list({ q: "external-id-or-request-id", limit: 25 });
136
+ const summary = await client.requests.getSummary(id);
137
+ const steps = await client.requests.listRecords(id, "steps", { limit: 25, offset: 0 });
138
+ const timeline = await client.requests.getTimeline(id);
139
+
140
+ const span = await client.spans.start(id, "application.context", { inputId });
141
+ try {
142
+ await rebuildContext();
143
+ await span.end();
144
+ } catch (error) {
145
+ await span.end({ status: "error" });
146
+ throw error;
147
+ }
74
148
  ```
75
149
 
76
- `resolve` does not verify provider credentials or future availability. The console
77
- at `/test` is the SDK playground, with real LLM calls and local mock tools. Its
78
- Resolve only option calls this same method. Inspection includes original `input`
79
- and the current `effectiveInput` after application context refreshes.
150
+ `requests.get(id)` loads full inspection. For long runs, use `getSummary()` and `listRecords()` for steps, attempts, inputs, spans or logs. `getTimeline()` returns the same recorded and derived rows as the console. `getHttpExchange()` and `getHttpBody()` inspect a provider attempt. Caller spans may link to an `inputId`, `stepId`, `attemptId` or `parentSpanId`; `client.spans.add()` and `client.logs.add()` write batches.
80
151
 
81
- For paginated monitoring, use `inspectSummary(id)` for request context, then
82
- `records(id, "logs", { level: "error", q: "tool", offset: 0, limit: 25 })`.
83
- Supported record kinds are `steps`, `attempts`, `inputs`, `spans`, and `logs`;
84
- responses contain `items`, `total`, `offset`, and `limit`. Summary inspection
85
- intentionally contains empty telemetry arrays; `inspect` retains its full behavior.
152
+ User messages may contain HTTPS image or PDF URL parts. Constellation stores URL references, not media bytes. Provider adapters forward supported URLs or download within limits immediately before dispatch. Keep signed URLs valid until the provider fetches them; refresh URLs in a later agent `prepareStep` callback.
86
153
 
87
- ## Client reference
154
+ ## Other namespaces
88
155
 
89
- | Method | Behavior |
156
+ | Namespace | Methods |
90
157
  | --- | --- |
91
- | `submit(input, signal?)` | Submit a durable `generate` or `run` request and return `{ id }`. Persist `idempotencyKey` for retries. |
92
- | `resolve(input, signal?)` | Return the current resolution and rule snapshot without dispatch. |
93
- | `list(query?, signal?)` | Page requests; filter by `q`, `requestId`, `externalId`, `workspaceId`, `userId`, or `status`. |
94
- | `inspect(id, signal?)` | Get full request context and telemetry. |
95
- | `inspectSummary(id, signal?)` | Get context without loading historical telemetry arrays. |
96
- | `records(id, kind, query?, signal?)` | Page one record kind with `id`, `q`, `level`, `offset`, and `limit`. |
97
- | `events(id, { after?, signal? })` | Async iterator over live SSE with durable cursor replay. Persist `seq` to resume. |
98
- | `drive(id, options)` | Claim and answer pending inputs until terminal; supports `tools`, `prepareStep`, `consumerId`, `toolTimeoutMs`, `onUpdate`, and `signal`. |
99
- | `claim(id, inputId, claimId, signal?)` / `respond(id, inputId, response, signal?)` | Manually handle a waiting input. |
100
- | `cancel(id, signal?)` | Explicitly cancel a durable request. |
101
- | `addSpans(id, spans, signal?)` / `startSpan(id, name, options?, signal?)` | Upsert caller spans; `startSpan` returns an `end()` handle. |
102
- | `addLogs(id, logs, signal?)` | Append caller logs. |
103
-
104
- `RequestInput` requires `idempotencyKey`, `workspaceId`, `model`, `kind`, and
105
- `messages`. It also accepts `externalId`, `userId`, `planId`, `instructions`,
106
- `tools`, `parameters`, `maxSteps`, `timeoutMs`, `maxOutputTokens`,
107
- `prepareStep` and `cache`. Set `cache.mode` to `automatic` for provider-native
108
- prefix caching or `explicit` with up to four `points` for exact breakpoints.
109
- Points target tools, instructions, or a message index and optional `partIndex`
110
- within `contentParts`. Optional `affinityKey` and `isolationKey` are sent only
111
- through fields configured on the selected provider. An isolation key excludes
112
- providers without a mapping; explicit mode excludes providers without exact
113
- marker support. The selected attempt records whether affinity was applied.
114
- `kind: "generate"` is a single generation;
115
- `kind: "run"` can pause for tools or context refreshes. The SDK exports
116
- `uuid7`, typed request/inspection records, and `ConstellationError` with HTTP status,
117
- error code, and message. All calls use the configured timeout (30 seconds by
118
- default) and an optional abort signal.
119
-
120
- This package covers execution requests. Model discovery (`GET /v1/models`,
121
- `POST /v1/registry/resolve`), provider HTTP capture, the management APIs, and
122
- operator APIs are available over HTTP; see the [HTTP API guide](../../docs/registry-api.md)
123
- and the in-app Documentation page for those endpoint contracts. `events()` uses
124
- SSE and reconnects from the durable cursor. Aborting `drive()` disconnects the consumer but does not
125
- cancel the Constellation request.
158
+ | `management.registry` | `get`, `listRows`, `update`, `applyChanges`, `listHistory`, `rollback`, `preview`, `testRules` |
159
+ | `management.keys` | `list`, `listPage`, `create`, `revoke`, `setInputWebhook` |
160
+ | `management.credentials` | `list`, `listPage`, `create`, `update`, `delete` |
161
+ | `management.executionSettings` | `get`, `update`, `listHistory` |
162
+ | `telemetry.requests` | `list`, `get`, `getSummary`, `listEvents`, `listRecords`, `getTimeline`, `getHttpExchange`, `getHttpBody` |
163
+ | `telemetry.system` | `get`, `listHistory`, `getComponent` |
164
+ | `telemetry.observability` | `get`, `getSeries`, `getTiming` |
165
+ | `telemetry.jobs` | `listHistory` |
166
+ | `telemetry.notifications` | `list`, `get`, `listEvents` |
167
+ | `telemetry.logs` | `searchSystem`, `searchRequests`, `searchAudit` |
168
+ | `health` | `live`, `ready` (public probes) |
169
+
170
+ Top-level `logs.add()` writes caller logs on an execution key. `telemetry.logs` searches operational logs with a telemetry key. The API's exact parameter and response schemas are served at `/v1/openapi.json` and shown inline in the console's API reference.
171
+
172
+ The client defaults to a 30-second HTTP timeout; configure `timeoutMs` or pass an `AbortSignal`. `ConstellationError` carries HTTP status, server code and message. Older flat method names remain as temporary aliases for existing callers; new integrations should use the names above.
package/dist/index.d.ts CHANGED
@@ -15,8 +15,12 @@ export type ToolCall = {
15
15
  export type Message = {
16
16
  role: "system" | "user" | "assistant" | "tool";
17
17
  text?: string;
18
- /** Ordered text parts, used when a cache point must end within a message. */
19
- contentParts?: string[];
18
+ /** Ordered text and HTTPS media references. Media bytes are never submitted. */
19
+ contentParts?: (string | {
20
+ type: "image" | "file";
21
+ url: string;
22
+ mediaType: string;
23
+ })[];
20
24
  nativeContent?: Json;
21
25
  toolCalls?: ToolCall[];
22
26
  toolResults?: {
@@ -25,14 +29,13 @@ export type Message = {
25
29
  isError?: boolean;
26
30
  }[];
27
31
  };
28
- export type RequestInput = {
32
+ type RequestFields = {
29
33
  idempotencyKey: string;
30
34
  externalId?: string;
31
35
  workspaceId: string;
32
36
  userId?: string;
33
37
  planId?: string;
34
38
  model: string;
35
- kind: "generate" | "run";
36
39
  instructions?: string;
37
40
  messages: Message[];
38
41
  tools?: Tool[];
@@ -40,7 +43,6 @@ export type RequestInput = {
40
43
  maxSteps?: number;
41
44
  timeoutMs?: number;
42
45
  maxOutputTokens?: number;
43
- prepareStep?: boolean;
44
46
  cache?: {
45
47
  mode: "automatic" | "explicit";
46
48
  points?: ({
@@ -54,13 +56,25 @@ export type RequestInput = {
54
56
  isolationKey?: string;
55
57
  };
56
58
  };
59
+ /** An agent always supplies its first complete step at admission. Later steps use prepare_step. */
60
+ export type RequestInput = RequestFields & ({
61
+ kind: "generate";
62
+ tools?: never;
63
+ prepareStep?: boolean;
64
+ } | {
65
+ kind: "run";
66
+ prepareStep?: boolean;
67
+ } | {
68
+ kind: "agent";
69
+ prepareStep?: true;
70
+ });
57
71
  export type Status = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled";
58
72
  export type RequestRecord = {
59
73
  id: string;
60
74
  externalId: string | null;
61
75
  workspaceId: string;
62
76
  userId: string | null;
63
- kind: "generate" | "run";
77
+ kind: "generate" | "run" | "agent";
64
78
  requestedModel: string;
65
79
  virtualModel: boolean;
66
80
  planId: string | null;
@@ -88,6 +102,9 @@ export type Step = {
88
102
  idx: number;
89
103
  input: Json;
90
104
  inputHash: string;
105
+ catalogRevision: number | null;
106
+ resolution: Json;
107
+ rules: Json;
91
108
  status: string;
92
109
  output: Json;
93
110
  usage: Json;
@@ -124,21 +141,54 @@ export type Attempt = {
124
141
  httpStatus: number | null;
125
142
  ttfbMs: number | null;
126
143
  ttftMs: number | null;
144
+ /** First reasoning delta to first text delta, or stream end when no text arrives. */
145
+ reasoningMs?: number | null;
146
+ /** First text delta to provider stream end. Null when no text arrives. */
147
+ outputMs?: number | null;
127
148
  startedAt: string;
128
149
  endedAt: string | null;
129
150
  };
151
+ export type PreparedStep = {
152
+ model: string;
153
+ instructions: string;
154
+ messages: Message[];
155
+ tools: Tool[];
156
+ parameters: Record<string, Json>;
157
+ maxOutputTokens: number;
158
+ cache: RequestInput["cache"] | null;
159
+ };
160
+ export type CallerReview = {
161
+ stage: "after_model" | "after_tools";
162
+ stepId: string;
163
+ toolCalls?: ToolCall[];
164
+ toolResults?: Message["toolResults"];
165
+ usage?: Json;
166
+ customerCost?: string | null;
167
+ supplierCost?: string | null;
168
+ customerPrice?: Json;
169
+ output?: Json;
170
+ };
130
171
  export type PendingInput = {
131
172
  id: string;
132
173
  requestId: string;
133
174
  stepId: string | null;
134
- kind: "tool" | "prepare_step" | "user_input";
175
+ kind: "tool" | "prepare_step" | "caller_review" | "user_input";
135
176
  payload: {
136
177
  providerCallId?: string;
137
178
  name?: string;
138
179
  arguments?: Json;
139
180
  validationError?: string | null;
181
+ stage?: CallerReview["stage"];
182
+ toolCalls?: ToolCall[];
183
+ toolResults?: Message["toolResults"];
184
+ usage?: Json;
185
+ customerCost?: string | null;
186
+ supplierCost?: string | null;
187
+ customerPrice?: Json;
188
+ output?: Json;
140
189
  };
141
190
  claimId: string | null;
191
+ claimUntil?: string | null;
142
192
  response: Json;
143
193
  resolvedAt: string | null;
144
194
  };
@@ -146,6 +196,7 @@ export type Span = {
146
196
  id: string;
147
197
  stepId?: string | null;
148
198
  attemptId?: string | null;
199
+ inputId?: string | null;
149
200
  parentSpanId?: string | null;
150
201
  name: string;
151
202
  startedAt: string;
@@ -153,6 +204,39 @@ export type Span = {
153
204
  status: "running" | "ok" | "error" | "cancelled";
154
205
  attributes?: Record<string, Json>;
155
206
  };
207
+ export type TimelineRow = {
208
+ id: string;
209
+ requestId: string;
210
+ parentId: string | null;
211
+ kind: "request" | "step" | "attempt" | "input" | "span" | "phase" | "http_phase";
212
+ label: string;
213
+ status: string;
214
+ start: string;
215
+ end: string | null;
216
+ origin: "recorded" | "derived";
217
+ recordedParentId?: string | null;
218
+ recordedStart?: string | null;
219
+ recordedEnd?: string | null;
220
+ derivedFrom?: string[];
221
+ source?: "caller" | "constellation";
222
+ parentSpanId?: string | null;
223
+ inputId?: string | null;
224
+ stepId?: string | null;
225
+ attemptId?: string | null;
226
+ [key: string]: Json | undefined;
227
+ };
228
+ export type Timeline = {
229
+ workspaceId: string;
230
+ externalId: string | null;
231
+ rows: TimelineRow[];
232
+ total: number;
233
+ offset: number;
234
+ limit: number;
235
+ previousRequestId: string | null;
236
+ nextRequestId: string | null;
237
+ truncated: boolean;
238
+ asOf: string;
239
+ };
156
240
  export type Log = {
157
241
  id: string;
158
242
  stepId?: string | null;
@@ -186,14 +270,79 @@ export type InputResponse = {
186
270
  isError?: boolean;
187
271
  instructions?: string;
188
272
  tools?: Tool[];
189
- action?: "continue" | "refresh_context" | "wait_for_user" | "complete";
273
+ stepInput?: PreparedStep;
274
+ action?: "continue" | "refresh_context" | "wait_for_user" | "complete" | "fail";
275
+ };
276
+ export type InputHandlers = {
277
+ tools?: Record<string, (args: Json, context: {
278
+ inputId: string;
279
+ requestId: string;
280
+ signal?: AbortSignal;
281
+ }) => Promise<Json>>;
282
+ prepareStep?: (state: Inspection, context: {
283
+ inputId: string;
284
+ parentSpanId: string;
285
+ signal: AbortSignal;
286
+ }) => Promise<Pick<InputResponse, "instructions" | "tools" | "stepInput" | "action">>;
287
+ callerReview?: (review: CallerReview, state: Inspection, context: {
288
+ inputId: string;
289
+ signal: AbortSignal;
290
+ }) => Promise<Pick<InputResponse, "action" | "result">>;
291
+ userInput?: (state: Inspection, context: {
292
+ inputId: string;
293
+ signal: AbortSignal;
294
+ }) => Promise<Json>;
295
+ signal?: AbortSignal;
296
+ consumerId?: string;
297
+ toolTimeoutMs?: number;
298
+ onUpdate?: (state: Inspection) => void;
190
299
  };
300
+ /** @deprecated Use InputHandlers. */
301
+ export type DriveOptions = InputHandlers;
191
302
  export type RequestEvent = {
192
303
  seq: number;
193
304
  type: string;
194
305
  data: Record<string, Json>;
195
306
  timestamp: string;
196
307
  };
308
+ export type Page<T> = {
309
+ items: T[];
310
+ total: number;
311
+ offset: number;
312
+ limit: number;
313
+ };
314
+ export type PageQuery = {
315
+ offset?: number;
316
+ limit?: number;
317
+ q?: string;
318
+ filters?: string;
319
+ };
320
+ export type ModelScope = {
321
+ workspaceId: string;
322
+ userId?: string;
323
+ planId?: string;
324
+ };
325
+ export type ModelResolution = ModelScope & {
326
+ targetId: string;
327
+ inputTokens?: number;
328
+ outputTokens?: number;
329
+ parameters?: Record<string, Json>;
330
+ };
331
+ export type WebhookEvent = {
332
+ requestId: string;
333
+ inputId: string;
334
+ kind: "tool" | "prepare_step" | "caller_review" | "user_input";
335
+ payload: Record<string, Json>;
336
+ } | {
337
+ requestId: string;
338
+ kind: "request.model_events";
339
+ afterSeq: number;
340
+ events: Json[];
341
+ } | {
342
+ requestId: string;
343
+ eventSeq: number;
344
+ kind: "request.completed" | "request.failed" | "request.cancelled";
345
+ };
197
346
  export declare class ConstellationError extends Error {
198
347
  status: number;
199
348
  code: string;
@@ -216,6 +365,11 @@ export declare class Constellation {
216
365
  id: string;
217
366
  }>;
218
367
  inspect(id: string, signal?: AbortSignal): Promise<Inspection>;
368
+ /** Canonical API timeline shared with the console. Derived rows disclose their timestamp basis. */
369
+ timeline(id: string, options?: {
370
+ requestId?: string;
371
+ signal?: AbortSignal;
372
+ }): Promise<Timeline>;
219
373
  /** Request context without loading historical telemetry. Fetch records by page. */
220
374
  inspectSummary(id: string, signal?: AbortSignal): Promise<Inspection>;
221
375
  records<K extends keyof RecordTypes>(id: string, kind: K, query?: {
@@ -265,12 +419,16 @@ export declare class Constellation {
265
419
  }>;
266
420
  cancel(id: string, signal?: AbortSignal): Promise<void>;
267
421
  claim(id: string, inputId: string, claimId: string, signal?: AbortSignal): Promise<void>;
422
+ /** Agent claims are leases. Keep one alive while a caller handler is running. */
423
+ private maintainClaim;
424
+ private whileClaimed;
268
425
  respond(id: string, inputId: string, response: InputResponse, signal?: AbortSignal): Promise<void>;
269
426
  addSpans(id: string, spans: Span[], signal?: AbortSignal): Promise<void>;
270
427
  addLogs(id: string, logs: Log[], signal?: AbortSignal): Promise<void>;
271
428
  startSpan(id: string, name: string, options?: {
272
429
  stepId?: string;
273
430
  attemptId?: string;
431
+ inputId?: string;
274
432
  parentSpanId?: string;
275
433
  startedAt?: string;
276
434
  attributes?: Record<string, Json>;
@@ -287,20 +445,191 @@ export declare class Constellation {
287
445
  after?: number;
288
446
  signal?: AbortSignal;
289
447
  }): 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;
448
+ /** Process one delivered input and exit. An input notification can be retried safely. */
449
+ handleInput(id: string, inputId: string, options?: DriveOptions): Promise<Inspection>;
450
+ /** Legacy long-running helper. Use handleInput from a durable notification handler. */
451
+ drive(id: string, options?: DriveOptions & {
452
+ targetInputId?: string;
305
453
  }): Promise<Inspection>;
454
+ /** Client-safe discovery with an execution key. */
455
+ get models(): {
456
+ list: (scope: ModelScope, signal?: AbortSignal) => Promise<Json>;
457
+ resolve: (input: ModelResolution, signal?: AbortSignal) => Promise<Json>;
458
+ };
459
+ /** Durable requests. create returns an ID; execution continues independently. */
460
+ get requests(): {
461
+ create: (input: RequestInput, signal?: AbortSignal) => Promise<{
462
+ id: string;
463
+ }>;
464
+ preview: (input: RequestInput, signal?: AbortSignal) => Promise<{
465
+ revision: number;
466
+ resolution: Record<string, Json>;
467
+ rules: Record<string, Json>;
468
+ allowed: boolean;
469
+ dispatched: false;
470
+ }>;
471
+ get: (id: string, signal?: AbortSignal) => Promise<Inspection>;
472
+ getSummary: (id: string, signal?: AbortSignal) => Promise<Inspection>;
473
+ list: (query?: Parameters<Constellation["list"]>[0], signal?: AbortSignal) => Promise<{
474
+ items: RequestRecord[];
475
+ total: number;
476
+ offset: number;
477
+ limit: number;
478
+ }>;
479
+ cancel: (id: string, signal?: AbortSignal) => Promise<void>;
480
+ listEvents: (id: string, after?: number, signal?: AbortSignal) => Promise<{
481
+ items: RequestEvent[];
482
+ }>;
483
+ streamEvents: (id: string, options?: {
484
+ after?: number;
485
+ signal?: AbortSignal;
486
+ }) => AsyncGenerator<RequestEvent, any, any>;
487
+ listRecords: <K extends keyof RecordTypes>(id: string, kind: K, query?: Parameters<Constellation["records"]>[2], signal?: AbortSignal) => Promise<{
488
+ items: RecordTypes[K][];
489
+ total: number;
490
+ offset: number;
491
+ limit: number;
492
+ }>;
493
+ getTimeline: (id: string, options?: {
494
+ requestId?: string;
495
+ signal?: AbortSignal;
496
+ }) => Promise<Timeline>;
497
+ getHttpExchange: (id: string, attemptId: string, signal?: AbortSignal) => Promise<Json>;
498
+ getHttpBody: (id: string, attemptId: string, query: {
499
+ direction: "request" | "response";
500
+ offset?: number;
501
+ limit?: number;
502
+ }, signal?: AbortSignal) => Promise<Json>;
503
+ };
504
+ /** The same handlers work with webhook delivery and explicit polling. */
505
+ get inputs(): {
506
+ handle: (requestId: string, inputId: string, handlers: InputHandlers) => Promise<Inspection>;
507
+ poll: (requestId: string, handlers: InputHandlers) => Promise<Inspection>;
508
+ claim: (requestId: string, inputId: string, claimId: string, signal?: AbortSignal) => Promise<void>;
509
+ respond: (requestId: string, inputId: string, response: InputResponse, signal?: AbortSignal) => Promise<void>;
510
+ };
511
+ get spans(): {
512
+ start: (requestId: string, name: string, options?: Parameters<Constellation["startSpan"]>[2], signal?: AbortSignal) => Promise<{
513
+ id: string;
514
+ end: (options?: {
515
+ endedAt?: string;
516
+ status?: "ok" | "error" | "cancelled";
517
+ attributes?: Record<string, Json>;
518
+ }, signal?: AbortSignal) => Promise<void>;
519
+ }>;
520
+ add: (requestId: string, spans: Span[], signal?: AbortSignal) => Promise<void>;
521
+ };
522
+ get logs(): {
523
+ add: (requestId: string, logs: Log[], signal?: AbortSignal) => Promise<void>;
524
+ };
525
+ /** Configuration endpoints require a management key. */
526
+ get management(): {
527
+ registry: {
528
+ get: (signal?: AbortSignal) => Promise<Json>;
529
+ listRows: (kind: "models" | "providers" | "plans" | "rules", query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
530
+ update: (input: {
531
+ expectedRevision: number;
532
+ catalog: Json;
533
+ }, signal?: AbortSignal) => Promise<Json>;
534
+ applyChanges: (changes: Json[], signal?: AbortSignal) => Promise<Json>;
535
+ listHistory: (kind: string, id: string, query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
536
+ rollback: (kind: string, id: string, input: {
537
+ toVersion: number;
538
+ expectedVersion: number;
539
+ }, signal?: AbortSignal) => Promise<Json>;
540
+ preview: (input: ModelResolution, signal?: AbortSignal) => Promise<Json>;
541
+ testRules: (scope: {
542
+ workspaceId: string;
543
+ userId?: string;
544
+ }, signal?: AbortSignal) => Promise<Json>;
545
+ };
546
+ keys: {
547
+ list: (signal?: AbortSignal) => Promise<Json>;
548
+ listPage: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
549
+ create: (input: Json, signal?: AbortSignal) => Promise<Json>;
550
+ revoke: (id: string, signal?: AbortSignal) => Promise<void>;
551
+ setInputWebhook: (id: string, input: {
552
+ url: string | null;
553
+ token?: string;
554
+ }, signal?: AbortSignal) => Promise<Json>;
555
+ };
556
+ credentials: {
557
+ list: (signal?: AbortSignal) => Promise<Json>;
558
+ listPage: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
559
+ create: (input: Json, signal?: AbortSignal) => Promise<Json>;
560
+ update: (ref: string, input: Json, signal?: AbortSignal) => Promise<void>;
561
+ delete: (ref: string, signal?: AbortSignal) => Promise<void>;
562
+ };
563
+ executionSettings: {
564
+ get: (signal?: AbortSignal) => Promise<Json>;
565
+ update: (input: Json, signal?: AbortSignal) => Promise<Json>;
566
+ listHistory: (signal?: AbortSignal) => Promise<Json>;
567
+ };
568
+ };
569
+ /** Read-only operational endpoints require a telemetry key. */
570
+ get telemetry(): {
571
+ requests: {
572
+ list: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<RequestRecord>>;
573
+ get: (id: string, signal?: AbortSignal) => Promise<Inspection>;
574
+ getSummary: (id: string, signal?: AbortSignal) => Promise<Inspection>;
575
+ listEvents: (id: string, after?: number, signal?: AbortSignal) => Promise<{
576
+ items: RequestEvent[];
577
+ }>;
578
+ listRecords: <K extends keyof RecordTypes>(id: string, kind: K, query?: PageQuery, signal?: AbortSignal) => Promise<Page<RecordTypes[K]>>;
579
+ getTimeline: (id: string, options?: {
580
+ requestId?: string;
581
+ signal?: AbortSignal;
582
+ }) => Promise<Timeline>;
583
+ getHttpExchange: (id: string, attemptId: string, signal?: AbortSignal) => Promise<Json>;
584
+ getHttpBody: (id: string, attemptId: string, query: {
585
+ direction: "request" | "response";
586
+ offset?: number;
587
+ limit?: number;
588
+ }, signal?: AbortSignal) => Promise<Json>;
589
+ };
590
+ system: {
591
+ get: (signal?: AbortSignal) => Promise<Json>;
592
+ listHistory: (query?: {
593
+ hours?: 1 | 24 | 168;
594
+ }, signal?: AbortSignal) => Promise<Json>;
595
+ getComponent: (component: string, signal?: AbortSignal) => Promise<Json>;
596
+ };
597
+ observability: {
598
+ get: (query?: PageQuery, signal?: AbortSignal) => Promise<Json>;
599
+ getSeries: (query: {
600
+ source: "local" | "gateway";
601
+ hours?: 1 | 24 | 168;
602
+ providerId?: string;
603
+ modelId?: string;
604
+ }, signal?: AbortSignal) => Promise<Json>;
605
+ getTiming: (query?: {
606
+ hours?: 1 | 24 | 168;
607
+ }, signal?: AbortSignal) => Promise<Json>;
608
+ };
609
+ jobs: {
610
+ listHistory: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
611
+ };
612
+ notifications: {
613
+ list: (query?: PageQuery & {
614
+ status?: "active" | "resolved";
615
+ }, signal?: AbortSignal) => Promise<Page<Json>>;
616
+ get: (id: string, query?: PageQuery, signal?: AbortSignal) => Promise<Json>;
617
+ listEvents: (query?: {
618
+ after?: number;
619
+ }, signal?: AbortSignal) => Promise<Json>;
620
+ };
621
+ logs: {
622
+ searchSystem: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
623
+ searchRequests: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
624
+ searchAudit: (query?: PageQuery, signal?: AbortSignal) => Promise<Page<Json>>;
625
+ };
626
+ };
627
+ get health(): {
628
+ live: (signal?: AbortSignal) => Promise<Json>;
629
+ ready: (signal?: AbortSignal) => Promise<Json>;
630
+ };
631
+ get webhooks(): {
632
+ verify: (request: Request, expectedToken: string) => Promise<WebhookEvent>;
633
+ };
306
634
  }
635
+ export {};
package/dist/index.js CHANGED
@@ -21,6 +21,13 @@ export function uuid7() {
21
21
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
22
22
  }
23
23
  const terminal = (s) => ["completed", "failed", "cancelled"].includes(s);
24
+ function queryString(values) {
25
+ const params = new URLSearchParams();
26
+ for (const [key, value] of Object.entries(values))
27
+ if (value !== undefined && value !== null)
28
+ params.set(key, String(value));
29
+ return params.size ? `?${params}` : "";
30
+ }
24
31
  async function pause(ms, signal) {
25
32
  signal?.throwIfAborted();
26
33
  await new Promise((resolve, reject) => {
@@ -53,7 +60,10 @@ export class Constellation {
53
60
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
54
61
  }
55
62
  url(path) {
56
- return `${this.options.baseUrl.replace(/\/$/, "")}${path.replace(/^\/v1(?=\/)/, this.options.apiPrefix ?? "/v1")}`;
63
+ const route = this.options.apiPrefix === "/internal"
64
+ ? path.replace(/^\/v1(?:\/management|\/telemetry)?(?=\/)/, "/internal")
65
+ : path;
66
+ return `${this.options.baseUrl.replace(/\/$/, "")}${route}`;
57
67
  }
58
68
  async call(path, method = "GET", body, signal) {
59
69
  const response = await this.fetcher(this.url(path), {
@@ -81,11 +91,19 @@ export class Constellation {
81
91
  : response.json();
82
92
  }
83
93
  submit(input, signal) {
84
- return this.call("/v1/requests", "POST", input, signal);
94
+ return this.call("/v1/requests", "POST", input.kind === "agent" ? { ...input, prepareStep: true } : input, signal);
85
95
  }
86
96
  inspect(id, signal) {
87
97
  return this.call(`/v1/requests/${encodeURIComponent(id)}`, "GET", undefined, signal);
88
98
  }
99
+ /** Canonical API timeline shared with the console. Derived rows disclose their timestamp basis. */
100
+ timeline(id, options = {}) {
101
+ const params = new URLSearchParams();
102
+ if (options.requestId)
103
+ params.set("requestId", options.requestId);
104
+ const query = params.size ? `?${params}` : "";
105
+ return this.call(`/v1/requests/${encodeURIComponent(id)}/timeline${query}`, "GET", undefined, options.signal);
106
+ }
89
107
  /** Request context without loading historical telemetry. Fetch records by page. */
90
108
  inspectSummary(id, signal) {
91
109
  return this.call(`/v1/requests/${encodeURIComponent(id)}?summary=true`, "GET", undefined, signal);
@@ -98,7 +116,7 @@ export class Constellation {
98
116
  return this.call(`/v1/requests/${encodeURIComponent(id)}/records/${kind}?${params}`, "GET", undefined, signal);
99
117
  }
100
118
  resolve(input, signal) {
101
- return this.call("/v1/requests/resolve", "POST", input, signal);
119
+ return this.call("/v1/requests/resolve", "POST", input.kind === "agent" ? { ...input, prepareStep: true } : input, signal);
102
120
  }
103
121
  list(query = {}, signal) {
104
122
  const params = new URLSearchParams();
@@ -113,6 +131,48 @@ export class Constellation {
113
131
  claim(id, inputId, claimId, signal) {
114
132
  return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}/claim`, "POST", { claimId }, signal);
115
133
  }
134
+ /** Agent claims are leases. Keep one alive while a caller handler is running. */
135
+ maintainClaim(id, inputId, claimId, externalSignal) {
136
+ const controller = new AbortController();
137
+ const signal = externalSignal
138
+ ? AbortSignal.any([externalSignal, controller.signal])
139
+ : controller.signal;
140
+ const renewal = (async () => {
141
+ while (!signal.aborted) {
142
+ try {
143
+ await pause(15_000, signal);
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ if (signal.aborted)
149
+ return;
150
+ try {
151
+ await this.claim(id, inputId, claimId, signal);
152
+ }
153
+ catch (error) {
154
+ controller.abort(error);
155
+ return;
156
+ }
157
+ }
158
+ })();
159
+ return {
160
+ signal,
161
+ stop: async () => {
162
+ controller.abort();
163
+ await renewal;
164
+ },
165
+ };
166
+ }
167
+ async whileClaimed(id, inputId, claimId, leased, operation, signal) {
168
+ const lease = leased ? this.maintainClaim(id, inputId, claimId, signal) : null;
169
+ try {
170
+ return await operation(lease?.signal ?? signal);
171
+ }
172
+ finally {
173
+ await lease?.stop();
174
+ }
175
+ }
116
176
  respond(id, inputId, response, signal) {
117
177
  return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}`, "POST", response, signal);
118
178
  }
@@ -219,7 +279,11 @@ export class Constellation {
219
279
  await pause(500, options.signal);
220
280
  }
221
281
  }
222
- /** Disconnecting this consumer leaves unresolved inputs durably waiting in Constellation. */
282
+ /** Process one delivered input and exit. An input notification can be retried safely. */
283
+ async handleInput(id, inputId, options = {}) {
284
+ return this.drive(id, { ...options, targetInputId: inputId });
285
+ }
286
+ /** Legacy long-running helper. Use handleInput from a durable notification handler. */
223
287
  async drive(id, options = {}) {
224
288
  const consumerId = options.consumerId ?? uuid7();
225
289
  while (true) {
@@ -227,10 +291,20 @@ export class Constellation {
227
291
  options.onUpdate?.(state);
228
292
  if (terminal(state.request.status))
229
293
  return state;
230
- for (const pending of state.inputs.filter((i) => !i.resolvedAt)) {
231
- if (pending.claimId && pending.claimId !== consumerId)
294
+ if (options.targetInputId) {
295
+ const target = state.inputs.find((input) => input.id === options.targetInputId);
296
+ if (!target)
297
+ throw new Error(`Input ${options.targetInputId} is not in request ${id}`);
298
+ if (target.resolvedAt)
299
+ return state;
300
+ }
301
+ let handled = false;
302
+ for (const pending of state.inputs.filter((i) => !i.resolvedAt && (!options.targetInputId || i.id === options.targetInputId))) {
303
+ if (pending.claimId && pending.claimId !== consumerId &&
304
+ !(state.request.kind === "agent" && pending.claimUntil && Date.parse(pending.claimUntil) <= Date.now()))
232
305
  continue;
233
- if (pending.kind === "prepare_step" && options.prepareStep) {
306
+ const prepareStep = options.prepareStep;
307
+ if (pending.kind === "prepare_step" && prepareStep) {
234
308
  try {
235
309
  await this.claim(id, pending.id, consumerId, options.signal);
236
310
  }
@@ -239,27 +313,86 @@ export class Constellation {
239
313
  continue;
240
314
  throw error;
241
315
  }
242
- let closed = false;
243
- const preparation = await this.startSpan(id, "caller.prepare_step", { attributes: { inputId: pending.id } }, options.signal);
316
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
317
+ let closed = false;
318
+ const preparation = await this.startSpan(id, "caller.prepare_step", { inputId: pending.id }, activeSignal);
319
+ try {
320
+ const signal = activeSignal
321
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
322
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
323
+ const response = await bounded(prepareStep(state, { inputId: pending.id, parentSpanId: preparation.id, signal }), signal);
324
+ await preparation.end({}, activeSignal);
325
+ closed = true;
326
+ if (state.request.kind === "agent")
327
+ await this.claim(id, pending.id, consumerId, activeSignal);
328
+ await this.respond(id, pending.id, { ...response, claimId: consumerId }, activeSignal);
329
+ }
330
+ catch (error) {
331
+ if (!activeSignal?.aborted && !closed)
332
+ await preparation.end({ status: "error" }, activeSignal).catch(() => { });
333
+ throw error;
334
+ }
335
+ }, options.signal);
336
+ handled = true;
337
+ continue;
338
+ }
339
+ const callerReview = options.callerReview;
340
+ if (pending.kind === "caller_review" && callerReview) {
341
+ const stage = pending.payload.stage;
342
+ if (!pending.stepId || (stage !== "after_model" && stage !== "after_tools"))
343
+ throw new Error("Caller review is missing its step or stage");
344
+ const stepId = pending.stepId;
345
+ const review = { ...pending.payload, stepId, stage };
244
346
  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);
347
+ await this.claim(id, pending.id, consumerId, options.signal);
348
+ }
349
+ catch (error) {
350
+ if (error instanceof ConstellationError && error.status === 409)
351
+ continue;
352
+ throw error;
353
+ }
354
+ await this.whileClaimed(id, pending.id, consumerId, true, async (activeSignal) => {
355
+ const reviewSpan = await this.startSpan(id, "caller.review", { stepId, inputId: pending.id, attributes: { stage } }, activeSignal);
356
+ let closed = false;
357
+ try {
358
+ const signal = activeSignal
359
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
360
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
361
+ const response = await bounded(callerReview(review, state, { inputId: pending.id, signal }), signal);
362
+ await reviewSpan.end({}, activeSignal);
363
+ closed = true;
364
+ await this.claim(id, pending.id, consumerId, activeSignal);
365
+ await this.respond(id, pending.id, { ...response, claimId: consumerId }, activeSignal);
366
+ }
367
+ catch (error) {
368
+ if (!activeSignal?.aborted && !closed)
369
+ await reviewSpan.end({ status: "error" }, activeSignal).catch(() => { });
370
+ throw error;
371
+ }
372
+ }, options.signal);
373
+ handled = true;
374
+ continue;
375
+ }
376
+ const userInput = options.userInput;
377
+ if (pending.kind === "user_input" && userInput) {
378
+ try {
379
+ await this.claim(id, pending.id, consumerId, options.signal);
255
380
  }
256
381
  catch (error) {
257
- if (!options.signal?.aborted && !closed)
258
- await preparation
259
- .end({ status: "error" }, options.signal)
260
- .catch(() => { });
382
+ if (error instanceof ConstellationError && error.status === 409)
383
+ continue;
261
384
  throw error;
262
385
  }
386
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
387
+ const signal = activeSignal
388
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
389
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
390
+ const result = await bounded(userInput(state, { inputId: pending.id, signal }), signal);
391
+ if (state.request.kind === "agent")
392
+ await this.claim(id, pending.id, consumerId, activeSignal);
393
+ await this.respond(id, pending.id, { result, claimId: consumerId }, activeSignal);
394
+ }, options.signal);
395
+ handled = true;
263
396
  continue;
264
397
  }
265
398
  if (pending.kind !== "tool")
@@ -275,40 +408,193 @@ export class Constellation {
275
408
  continue;
276
409
  throw error;
277
410
  }
278
- const span = await this.startSpan(id, `tool.${pending.payload.name}`, {
279
- stepId: pending.stepId ?? undefined,
280
- attributes: { inputId: pending.id },
411
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
412
+ const span = await this.startSpan(id, `tool.${pending.payload.name}`, { stepId: pending.stepId ?? undefined, inputId: pending.id }, activeSignal);
413
+ let result;
414
+ let isError = false;
415
+ try {
416
+ if (pending.payload.validationError)
417
+ throw new Error(pending.payload.validationError);
418
+ if (!tool)
419
+ throw new Error("Tool unavailable");
420
+ const toolSignal = activeSignal
421
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
422
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
423
+ result = await bounded(tool(pending.payload.arguments ?? {}, {
424
+ inputId: pending.id,
425
+ requestId: id,
426
+ signal: toolSignal,
427
+ }), toolSignal);
428
+ }
429
+ catch (error) {
430
+ activeSignal?.throwIfAborted();
431
+ isError = true;
432
+ result = { error: error instanceof Error ? error.message : "Tool failed" };
433
+ }
434
+ await span.end({ status: isError ? "error" : "ok" }, activeSignal);
435
+ if (state.request.kind === "agent")
436
+ await this.claim(id, pending.id, consumerId, activeSignal);
437
+ await this.respond(id, pending.id, { result, isError, claimId: consumerId }, activeSignal);
281
438
  }, 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);
439
+ handled = true;
440
+ }
441
+ if (options.targetInputId) {
442
+ if (!handled)
443
+ throw new Error(`Input ${options.targetInputId} could not be handled`);
444
+ return this.inspect(id, options.signal);
310
445
  }
311
446
  await pause(500, options.signal);
312
447
  }
313
448
  }
449
+ /** Client-safe discovery with an execution key. */
450
+ get models() {
451
+ return {
452
+ list: (scope, signal) => this.call(`/v1/models${queryString(scope)}`, "GET", undefined, signal),
453
+ resolve: (input, signal) => this.call("/v1/registry/resolve", "POST", input, signal),
454
+ };
455
+ }
456
+ /** Durable requests. create returns an ID; execution continues independently. */
457
+ get requests() {
458
+ return {
459
+ create: (input, signal) => this.submit(input, signal),
460
+ preview: (input, signal) => this.resolve(input, signal),
461
+ get: (id, signal) => this.inspect(id, signal),
462
+ getSummary: (id, signal) => this.inspectSummary(id, signal),
463
+ list: (query = {}, signal) => this.list(query, signal),
464
+ cancel: (id, signal) => this.cancel(id, signal),
465
+ listEvents: (id, after = 0, signal) => this.call(`/v1/requests/${encodeURIComponent(id)}/events${queryString({ after })}`, "GET", undefined, signal),
466
+ streamEvents: (id, options = {}) => this.events(id, options),
467
+ listRecords: (id, kind, query = {}, signal) => this.records(id, kind, query, signal),
468
+ getTimeline: (id, options = {}) => this.timeline(id, options),
469
+ getHttpExchange: (id, attemptId, signal) => this.call(`/v1/requests/${encodeURIComponent(id)}/http/${encodeURIComponent(attemptId)}`, "GET", undefined, signal),
470
+ getHttpBody: (id, attemptId, query, signal) => this.call(`/v1/requests/${encodeURIComponent(id)}/http/${encodeURIComponent(attemptId)}/body${queryString(query)}`, "GET", undefined, signal),
471
+ };
472
+ }
473
+ /** The same handlers work with webhook delivery and explicit polling. */
474
+ get inputs() {
475
+ return {
476
+ handle: (requestId, inputId, handlers) => this.handleInput(requestId, inputId, handlers),
477
+ poll: (requestId, handlers) => this.drive(requestId, handlers),
478
+ claim: (requestId, inputId, claimId, signal) => this.claim(requestId, inputId, claimId, signal),
479
+ respond: (requestId, inputId, response, signal) => this.respond(requestId, inputId, response, signal),
480
+ };
481
+ }
482
+ get spans() {
483
+ return {
484
+ start: (requestId, name, options = {}, signal) => this.startSpan(requestId, name, options, signal),
485
+ add: (requestId, spans, signal) => this.addSpans(requestId, spans, signal),
486
+ };
487
+ }
488
+ get logs() {
489
+ return { add: (requestId, logs, signal) => this.addLogs(requestId, logs, signal) };
490
+ }
491
+ /** Configuration endpoints require a management key. */
492
+ get management() {
493
+ return {
494
+ registry: {
495
+ get: (signal) => this.call("/v1/management/registry", "GET", undefined, signal),
496
+ listRows: (kind, query = {}, signal) => this.call(`/v1/management/registry/list/${kind}${queryString(query)}`, "GET", undefined, signal),
497
+ update: (input, signal) => this.call("/v1/management/registry", "PUT", input, signal),
498
+ applyChanges: (changes, signal) => this.call("/v1/management/registry/changes", "POST", { changes }, signal),
499
+ listHistory: (kind, id, query = {}, signal) => this.call(`/v1/management/registry/history/${encodeURIComponent(kind)}/${encodeURIComponent(id)}${queryString(query)}`, "GET", undefined, signal),
500
+ rollback: (kind, id, input, signal) => this.call(`/v1/management/registry/history/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/rollback`, "POST", input, signal),
501
+ preview: (input, signal) => this.call("/v1/management/registry/preview", "POST", input, signal),
502
+ testRules: (scope, signal) => this.call("/v1/management/registry/rules/test", "POST", scope, signal),
503
+ },
504
+ keys: {
505
+ list: (signal) => this.call("/v1/management/api-keys", "GET", undefined, signal),
506
+ listPage: (query = {}, signal) => this.call(`/v1/management/api-keys/page${queryString(query)}`, "GET", undefined, signal),
507
+ create: (input, signal) => this.call("/v1/management/api-keys", "POST", input, signal),
508
+ revoke: (id, signal) => this.call(`/v1/management/api-keys/${encodeURIComponent(id)}`, "DELETE", undefined, signal),
509
+ setInputWebhook: (id, input, signal) => this.call(`/v1/management/api-keys/${encodeURIComponent(id)}/input-webhook`, "PUT", input, signal),
510
+ },
511
+ credentials: {
512
+ list: (signal) => this.call("/v1/management/credentials", "GET", undefined, signal),
513
+ listPage: (query = {}, signal) => this.call(`/v1/management/credentials/page${queryString(query)}`, "GET", undefined, signal),
514
+ create: (input, signal) => this.call("/v1/management/credentials", "POST", input, signal),
515
+ update: (ref, input, signal) => this.call(`/v1/management/credentials/${encodeURIComponent(ref)}`, "PUT", input, signal),
516
+ delete: (ref, signal) => this.call(`/v1/management/credentials/${encodeURIComponent(ref)}`, "DELETE", undefined, signal),
517
+ },
518
+ executionSettings: {
519
+ get: (signal) => this.call("/v1/management/execution-settings", "GET", undefined, signal),
520
+ update: (input, signal) => this.call("/v1/management/execution-settings", "PUT", input, signal),
521
+ listHistory: (signal) => this.call("/v1/management/execution-settings/history", "GET", undefined, signal),
522
+ },
523
+ };
524
+ }
525
+ /** Read-only operational endpoints require a telemetry key. */
526
+ get telemetry() {
527
+ const requestPath = (id) => `/v1/telemetry/requests/${encodeURIComponent(id)}`;
528
+ return {
529
+ requests: {
530
+ list: (query = {}, signal) => this.call(`/v1/telemetry/requests${queryString(query)}`, "GET", undefined, signal),
531
+ get: (id, signal) => this.call(requestPath(id), "GET", undefined, signal),
532
+ getSummary: (id, signal) => this.call(`${requestPath(id)}?summary=true`, "GET", undefined, signal),
533
+ listEvents: (id, after = 0, signal) => this.call(`${requestPath(id)}/events${queryString({ after })}`, "GET", undefined, signal),
534
+ listRecords: (id, kind, query = {}, signal) => this.call(`${requestPath(id)}/records/${kind}${queryString(query)}`, "GET", undefined, signal),
535
+ getTimeline: (id, options = {}) => this.call(`${requestPath(id)}/timeline${queryString({ requestId: options.requestId })}`, "GET", undefined, options.signal),
536
+ getHttpExchange: (id, attemptId, signal) => this.call(`${requestPath(id)}/http/${encodeURIComponent(attemptId)}`, "GET", undefined, signal),
537
+ getHttpBody: (id, attemptId, query, signal) => this.call(`${requestPath(id)}/http/${encodeURIComponent(attemptId)}/body${queryString(query)}`, "GET", undefined, signal),
538
+ },
539
+ system: {
540
+ get: (signal) => this.call("/v1/telemetry/system", "GET", undefined, signal),
541
+ listHistory: (query = {}, signal) => this.call(`/v1/telemetry/system/history${queryString(query)}`, "GET", undefined, signal),
542
+ getComponent: (component, signal) => this.call(`/v1/telemetry/system/${encodeURIComponent(component)}`, "GET", undefined, signal),
543
+ },
544
+ observability: {
545
+ get: (query = {}, signal) => this.call(`/v1/telemetry/observability${queryString(query)}`, "GET", undefined, signal),
546
+ getSeries: (query, signal) => this.call(`/v1/telemetry/observability/series${queryString(query)}`, "GET", undefined, signal),
547
+ getTiming: (query = {}, signal) => this.call(`/v1/telemetry/observability/timing${queryString(query)}`, "GET", undefined, signal),
548
+ },
549
+ jobs: {
550
+ listHistory: (query = {}, signal) => this.call(`/v1/telemetry/jobs/history${queryString(query)}`, "GET", undefined, signal),
551
+ },
552
+ notifications: {
553
+ list: (query = {}, signal) => this.call(`/v1/telemetry/notifications${queryString(query)}`, "GET", undefined, signal),
554
+ get: (id, query = {}, signal) => this.call(`/v1/telemetry/notifications/${encodeURIComponent(id)}${queryString(query)}`, "GET", undefined, signal),
555
+ listEvents: (query = {}, signal) => this.call(`/v1/telemetry/notifications/events${queryString(query)}`, "GET", undefined, signal),
556
+ },
557
+ logs: {
558
+ searchSystem: (query = {}, signal) => this.call(`/v1/telemetry/logs/system${queryString(query)}`, "GET", undefined, signal),
559
+ searchRequests: (query = {}, signal) => this.call(`/v1/telemetry/logs/requests${queryString(query)}`, "GET", undefined, signal),
560
+ searchAudit: (query = {}, signal) => this.call(`/v1/telemetry/logs/audit${queryString(query)}`, "GET", undefined, signal),
561
+ },
562
+ };
563
+ }
564
+ get health() {
565
+ return {
566
+ live: (signal) => this.call("/healthz", "GET", undefined, signal),
567
+ ready: (signal) => this.call("/readyz", "GET", undefined, signal),
568
+ };
569
+ }
570
+ get webhooks() {
571
+ return {
572
+ verify: async (request, expectedToken) => {
573
+ if (!expectedToken)
574
+ throw new Error("Constellation webhook token is not configured");
575
+ const actual = request.headers.get("x-constellation-webhook-token") ?? "";
576
+ let mismatch = actual.length ^ expectedToken.length;
577
+ for (let i = 0; i < Math.max(actual.length, expectedToken.length); i++)
578
+ mismatch |= (actual.charCodeAt(i) || 0) ^ (expectedToken.charCodeAt(i) || 0);
579
+ if (mismatch !== 0)
580
+ throw new Error("Invalid Constellation webhook token");
581
+ const body = await request.json();
582
+ if (!body || typeof body !== "object")
583
+ throw new Error("Invalid Constellation webhook body");
584
+ const event = body;
585
+ if (typeof event.requestId !== "string" || !event.requestId)
586
+ throw new Error("Invalid Constellation request ID");
587
+ if (typeof event.inputId === "string" && ["tool", "prepare_step", "caller_review", "user_input"].includes(String(event.kind))) {
588
+ if (request.headers.get("x-constellation-input-id") !== event.inputId || !event.payload || typeof event.payload !== "object" || Array.isArray(event.payload))
589
+ throw new Error("Invalid Constellation input notification");
590
+ return event;
591
+ }
592
+ if (event.kind === "request.model_events" && Number.isSafeInteger(event.afterSeq) && Array.isArray(event.events))
593
+ return event;
594
+ if (["request.completed", "request.failed", "request.cancelled"].includes(String(event.kind)) && Number.isSafeInteger(event.eventSeq))
595
+ return event;
596
+ throw new Error("Unknown Constellation webhook event");
597
+ },
598
+ };
599
+ }
314
600
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@lionhummer/constellation",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
+ "description": "TypeScript client for durable model requests and caller-controlled agent execution",
4
5
  "type": "module",
5
6
  "license": "UNLICENSED",
6
7
  "repository": {