@lionhummer/constellation 0.1.0 → 0.1.2

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,124 +1,155 @@
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
+ Constellation runs durable model requests and agent steps. This SDK submits requests, streams events, handles caller waits, and records spans and logs. Your application owns its conversation, tool implementations, user interaction, and any accounting or policy decisions.
4
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.
5
+ ## Install and connect
6
+
7
+ ```bash
8
+ npm install @lionhummer/constellation
9
+ ```
10
+
11
+ Use an execution API key from a trusted server. The SDK sends it as a bearer token to the `/v1` API.
11
12
 
12
13
  ```ts
13
14
  import { Constellation, uuid7 } from "@lionhummer/constellation";
14
15
 
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
- }],
16
+ const client = new Constellation({
17
+ baseUrl: "https://constellation.example.com",
18
+ apiKey: process.env.CONSTELLATION_API_KEY,
32
19
  });
20
+ ```
33
21
 
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
- },
22
+ The default HTTP timeout is 30 seconds per call. Pass `timeoutMs` to the constructor or an `AbortSignal` to an individual method to change it. Keep API keys out of browser code.
23
+
24
+ ## Make a request
25
+
26
+ `generate` makes one model call. `run` can pause for tool results or a context refresh. `agent` gives the caller control before every model step and after each step's output.
27
+
28
+ ```ts
29
+ const { id } = await client.submit({
30
+ idempotencyKey: uuid7(),
31
+ workspaceId: "workspace-123",
32
+ externalId: "conversation-456",
33
+ model: "catalog/example-model",
34
+ kind: "generate",
35
+ messages: [{ role: "user", text: "Explain the result." }],
43
36
  });
44
- console.log(result.request.status, result.request.customerCost);
37
+
38
+ for await (const event of client.events(id)) {
39
+ console.log(event.seq, event.type, event.data);
40
+ }
41
+
42
+ const final = await client.inspect(id);
43
+ console.log(final.request.status, final.request.output);
45
44
  ```
46
45
 
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.
46
+ Persist `idempotencyKey` and reuse it if submission is retried. `externalId` groups and finds requests; it does not carry conversation history into the next request. To resume the event stream, persist the last event's `seq` and call `events(id, { after: lastSeq })`. The stream uses SSE and replays from a durable cursor after a disconnect. Disconnecting does not cancel the request; call `cancel(id)` to do that.
47
+
48
+ ## Run an agent
48
49
 
49
- Explicit span times support instrumentation outside Constellation:
50
+ An agent request starts with a `prepare_step` wait. Supply the **complete** step input there, including conversation messages and any updated context. Constellation executes the model call, then sends a `caller_review` wait with usage, cost, output, and pending tool calls. Tool handlers run only after the caller returns `continue`. After tools finish, a second review lets the caller choose another step or complete the request.
50
51
 
51
52
  ```ts
52
- const span = await client.startSpan(request.id, "application.lookup", {
53
- startedAt: lookupStartedAt,
54
- attributes: { source: "obvious" },
53
+ import type { Message } from "@lionhummer/constellation";
54
+
55
+ const history: Message[] = [{ role: "user", text: "Find the answer." }];
56
+ const { id } = await client.submit({
57
+ idempotencyKey: uuid7(),
58
+ workspaceId: "workspace-123",
59
+ model: "catalog/example-model",
60
+ kind: "agent",
61
+ maxSteps: 4,
62
+ prepareStep: true,
63
+ messages: history,
55
64
  });
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
- }]);
65
+
66
+ const final = await client.drive(id, {
67
+ consumerId: uuid7(), // Persist this ID if this consumer may reconnect.
68
+
69
+ prepareStep: async (state, { signal }) => {
70
+ signal.throwIfAborted();
71
+ return {
72
+ stepInput: {
73
+ model: state.request.requestedModel,
74
+ instructions: "Answer accurately and cite the tool result.",
75
+ messages: [...history],
76
+ tools: [{
77
+ name: "lookup",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: { query: { type: "string" } },
81
+ required: ["query"],
82
+ additionalProperties: false,
83
+ },
84
+ }],
85
+ parameters: {},
86
+ maxOutputTokens: 1024,
87
+ cache: null,
88
+ },
89
+ };
90
+ },
91
+
92
+ callerReview: async (review, _state, { inputId, signal }) => {
93
+ signal.throwIfAborted();
94
+ if (review.stage === "after_model") {
95
+ // If you account for usage, settle this review once using inputId.
96
+ // Usage, cost, output and tool calls are available before tool dispatch.
97
+ const output = review.output as { message?: string } | undefined;
98
+ history.push({
99
+ role: "assistant", text: output?.message ?? "",
100
+ toolCalls: review.toolCalls ?? [],
101
+ });
102
+ return { action: review.toolCalls?.length ? "continue" : "complete" };
103
+ }
104
+ // after_tools contains tool results, not a second model charge.
105
+ history.push({ role: "tool", toolResults: review.toolResults ?? [] });
106
+ return { action: "continue" };
107
+ },
108
+
109
+ tools: {
110
+ lookup: async (args, { inputId, signal }) => {
111
+ signal?.throwIfAborted();
112
+ // Deduplicate external side effects by inputId.
113
+ return { answer: JSON.stringify(args) };
114
+ },
115
+ },
116
+ });
117
+
118
+ console.log(final.request.status, final.steps);
61
119
  ```
62
120
 
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.
121
+ Build `stepInput.messages` from your current conversation each time `prepareStep` runs. Adapt the example’s text-only history handling if your provider returns richer content. Make history updates idempotent by `inputId` so a retried review does not duplicate messages. The review handler may return `continue`, `complete`, `wait_for_user`, or `fail`. A `wait_for_user` action creates a durable `user_input` wait; provide `userInput` to `drive()` or claim and respond to that input manually. Use `toolTimeoutMs` to bound callbacks if they need more than the 30-second default.
122
+
123
+ `drive()` polls request inspection to find pending inputs. It claims them before invoking callbacks, renews agent claims while callbacks run, and writes the response. Agent claims have a 60-second lease. A replacement consumer can take over after expiry, so use each `inputId` as an idempotency key for accounting or external tool effects. Reuse a stable `consumerId` when reconnecting the same consumer.
64
124
 
65
- Run SDK checks with `bun test packages/sdk` from the repository root. The web build also checks SDK types.
125
+ ## Record caller work
66
126
 
67
- Resolve the exact execution input without creating a request or calling a provider:
127
+ Spans can measure preparation, tool work, or a caller review. Set `inputId` to nest a span under that wait in the request timeline. The SDK's `drive()` helper automatically creates spans around its preparation, review, and tool callbacks; you can add your own:
68
128
 
69
129
  ```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
- ```
130
+ const span = await client.startSpan(id, "application.context_lookup", {
131
+ inputId: "pending-input-id",
132
+ startedAt: new Date().toISOString(),
133
+ });
74
134
 
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.
135
+ // Perform the work and close the span before resolving the wait.
136
+ await span.end({ status: "ok" });
137
+ ```
79
138
 
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.
139
+ Use `addSpans()` and `addLogs()` for batches. Caller timestamps are accepted, including work that started before submission. Submit and close spans before the request becomes terminal; later writes return HTTP 409.
85
140
 
86
- ## Client reference
141
+ ## Inspect and recover
87
142
 
88
- | Method | Behavior |
143
+ | Method | Purpose |
89
144
  | --- | --- |
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.
145
+ | `submit(input)` / `resolve(input)` | Submit a durable request, or preview routing and rules without dispatching. |
146
+ | `events(id, { after, signal })` | Stream live events with durable cursor replay. |
147
+ | `inspect(id)` / `inspectSummary(id)` | Read the full request or a lightweight summary. |
148
+ | `records(id, kind, query)` | Page `steps`, `attempts`, `inputs`, `spans`, or `logs`. |
149
+ | `list(query)` | Page and filter requests. |
150
+ | `drive(id, options)` | Handle pending preparation, review, tool, and user input waits until terminal. |
151
+ | `claim(id, inputId, consumerId)` / `respond(id, inputId, response)` | Handle a wait manually. |
152
+ | `startSpan()` / `addSpans()` / `addLogs()` | Attach caller telemetry. |
153
+ | `cancel(id)` | Cancel a request explicitly. |
154
+
155
+ For large histories, use `inspectSummary(id)` and page the needed records, for example `records(id, "logs", { level: "error", limit: 25, offset: 0 })`. `ConstellationError` exposes the HTTP `status`, server `code`, and message. See the [execution contract](https://github.com/FlatFilers/constellation/blob/main/docs/execution.md) for request states, caching, limits, and recovery semantics.
package/dist/index.d.ts CHANGED
@@ -32,7 +32,7 @@ export type RequestInput = {
32
32
  userId?: string;
33
33
  planId?: string;
34
34
  model: string;
35
- kind: "generate" | "run";
35
+ kind: "generate" | "run" | "agent";
36
36
  instructions?: string;
37
37
  messages: Message[];
38
38
  tools?: Tool[];
@@ -60,7 +60,7 @@ export type RequestRecord = {
60
60
  externalId: string | null;
61
61
  workspaceId: string;
62
62
  userId: string | null;
63
- kind: "generate" | "run";
63
+ kind: "generate" | "run" | "agent";
64
64
  requestedModel: string;
65
65
  virtualModel: boolean;
66
66
  planId: string | null;
@@ -88,6 +88,9 @@ export type Step = {
88
88
  idx: number;
89
89
  input: Json;
90
90
  inputHash: string;
91
+ catalogRevision: number | null;
92
+ resolution: Json;
93
+ rules: Json;
91
94
  status: string;
92
95
  output: Json;
93
96
  usage: Json;
@@ -127,18 +130,47 @@ export type Attempt = {
127
130
  startedAt: string;
128
131
  endedAt: string | null;
129
132
  };
133
+ export type PreparedStep = {
134
+ model: string;
135
+ instructions: string;
136
+ messages: Message[];
137
+ tools: Tool[];
138
+ parameters: Record<string, Json>;
139
+ maxOutputTokens: number;
140
+ cache: RequestInput["cache"] | null;
141
+ };
142
+ export type CallerReview = {
143
+ stage: "after_model" | "after_tools";
144
+ stepId: string;
145
+ toolCalls?: ToolCall[];
146
+ toolResults?: Message["toolResults"];
147
+ usage?: Json;
148
+ customerCost?: string | null;
149
+ supplierCost?: string | null;
150
+ customerPrice?: Json;
151
+ output?: Json;
152
+ };
130
153
  export type PendingInput = {
131
154
  id: string;
132
155
  requestId: string;
133
156
  stepId: string | null;
134
- kind: "tool" | "prepare_step" | "user_input";
157
+ kind: "tool" | "prepare_step" | "caller_review" | "user_input";
135
158
  payload: {
136
159
  providerCallId?: string;
137
160
  name?: string;
138
161
  arguments?: Json;
139
162
  validationError?: string | null;
163
+ stage?: CallerReview["stage"];
164
+ toolCalls?: ToolCall[];
165
+ toolResults?: Message["toolResults"];
166
+ usage?: Json;
167
+ customerCost?: string | null;
168
+ supplierCost?: string | null;
169
+ customerPrice?: Json;
170
+ output?: Json;
140
171
  };
141
172
  claimId: string | null;
173
+ claimUntil?: string | null;
142
174
  response: Json;
143
175
  resolvedAt: string | null;
144
176
  };
@@ -146,6 +178,7 @@ export type Span = {
146
178
  id: string;
147
179
  stepId?: string | null;
148
180
  attemptId?: string | null;
181
+ inputId?: string | null;
149
182
  parentSpanId?: string | null;
150
183
  name: string;
151
184
  startedAt: string;
@@ -186,7 +219,8 @@ export type InputResponse = {
186
219
  isError?: boolean;
187
220
  instructions?: string;
188
221
  tools?: Tool[];
189
- action?: "continue" | "refresh_context" | "wait_for_user" | "complete";
222
+ stepInput?: PreparedStep;
223
+ action?: "continue" | "refresh_context" | "wait_for_user" | "complete" | "fail";
190
224
  };
191
225
  export type RequestEvent = {
192
226
  seq: number;
@@ -265,12 +299,16 @@ export declare class Constellation {
265
299
  }>;
266
300
  cancel(id: string, signal?: AbortSignal): Promise<void>;
267
301
  claim(id: string, inputId: string, claimId: string, signal?: AbortSignal): Promise<void>;
302
+ /** Agent claims are leases. Keep one alive while a caller handler is running. */
303
+ private maintainClaim;
304
+ private whileClaimed;
268
305
  respond(id: string, inputId: string, response: InputResponse, signal?: AbortSignal): Promise<void>;
269
306
  addSpans(id: string, spans: Span[], signal?: AbortSignal): Promise<void>;
270
307
  addLogs(id: string, logs: Log[], signal?: AbortSignal): Promise<void>;
271
308
  startSpan(id: string, name: string, options?: {
272
309
  stepId?: string;
273
310
  attemptId?: string;
311
+ inputId?: string;
274
312
  parentSpanId?: string;
275
313
  startedAt?: string;
276
314
  attributes?: Record<string, Json>;
@@ -297,7 +335,15 @@ export declare class Constellation {
297
335
  prepareStep?: (state: Inspection, context: {
298
336
  inputId: string;
299
337
  signal: AbortSignal;
300
- }) => Promise<Pick<InputResponse, "instructions" | "tools" | "action">>;
338
+ }) => Promise<Pick<InputResponse, "instructions" | "tools" | "stepInput" | "action">>;
339
+ callerReview?: (review: CallerReview, state: Inspection, context: {
340
+ inputId: string;
341
+ signal: AbortSignal;
342
+ }) => Promise<Pick<InputResponse, "action" | "result">>;
343
+ userInput?: (state: Inspection, context: {
344
+ inputId: string;
345
+ signal: AbortSignal;
346
+ }) => Promise<Json>;
301
347
  signal?: AbortSignal;
302
348
  consumerId?: string;
303
349
  toolTimeoutMs?: number;
package/dist/index.js CHANGED
@@ -113,6 +113,48 @@ export class Constellation {
113
113
  claim(id, inputId, claimId, signal) {
114
114
  return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}/claim`, "POST", { claimId }, signal);
115
115
  }
116
+ /** Agent claims are leases. Keep one alive while a caller handler is running. */
117
+ maintainClaim(id, inputId, claimId, externalSignal) {
118
+ const controller = new AbortController();
119
+ const signal = externalSignal
120
+ ? AbortSignal.any([externalSignal, controller.signal])
121
+ : controller.signal;
122
+ const renewal = (async () => {
123
+ while (!signal.aborted) {
124
+ try {
125
+ await pause(15_000, signal);
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ if (signal.aborted)
131
+ return;
132
+ try {
133
+ await this.claim(id, inputId, claimId, signal);
134
+ }
135
+ catch (error) {
136
+ controller.abort(error);
137
+ return;
138
+ }
139
+ }
140
+ })();
141
+ return {
142
+ signal,
143
+ stop: async () => {
144
+ controller.abort();
145
+ await renewal;
146
+ },
147
+ };
148
+ }
149
+ async whileClaimed(id, inputId, claimId, leased, operation, signal) {
150
+ const lease = leased ? this.maintainClaim(id, inputId, claimId, signal) : null;
151
+ try {
152
+ return await operation(lease?.signal ?? signal);
153
+ }
154
+ finally {
155
+ await lease?.stop();
156
+ }
157
+ }
116
158
  respond(id, inputId, response, signal) {
117
159
  return this.call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}`, "POST", response, signal);
118
160
  }
@@ -228,9 +270,11 @@ export class Constellation {
228
270
  if (terminal(state.request.status))
229
271
  return state;
230
272
  for (const pending of state.inputs.filter((i) => !i.resolvedAt)) {
231
- if (pending.claimId && pending.claimId !== consumerId)
273
+ if (pending.claimId && pending.claimId !== consumerId &&
274
+ !(state.request.kind === "agent" && pending.claimUntil && Date.parse(pending.claimUntil) <= Date.now()))
232
275
  continue;
233
- if (pending.kind === "prepare_step" && options.prepareStep) {
276
+ const prepareStep = options.prepareStep;
277
+ if (pending.kind === "prepare_step" && prepareStep) {
234
278
  try {
235
279
  await this.claim(id, pending.id, consumerId, options.signal);
236
280
  }
@@ -239,27 +283,83 @@ export class Constellation {
239
283
  continue;
240
284
  throw error;
241
285
  }
242
- let closed = false;
243
- const preparation = await this.startSpan(id, "caller.prepare_step", { attributes: { inputId: pending.id } }, options.signal);
286
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
287
+ let closed = false;
288
+ const preparation = await this.startSpan(id, "caller.prepare_step", { inputId: pending.id }, activeSignal);
289
+ try {
290
+ const signal = activeSignal
291
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
292
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
293
+ const response = await bounded(prepareStep(state, { inputId: pending.id, signal }), signal);
294
+ await preparation.end({}, activeSignal);
295
+ closed = true;
296
+ if (state.request.kind === "agent")
297
+ await this.claim(id, pending.id, consumerId, activeSignal);
298
+ await this.respond(id, pending.id, { ...response, claimId: consumerId }, activeSignal);
299
+ }
300
+ catch (error) {
301
+ if (!activeSignal?.aborted && !closed)
302
+ await preparation.end({ status: "error" }, activeSignal).catch(() => { });
303
+ throw error;
304
+ }
305
+ }, options.signal);
306
+ continue;
307
+ }
308
+ const callerReview = options.callerReview;
309
+ if (pending.kind === "caller_review" && callerReview) {
310
+ const stage = pending.payload.stage;
311
+ if (!pending.stepId || (stage !== "after_model" && stage !== "after_tools"))
312
+ throw new Error("Caller review is missing its step or stage");
313
+ const stepId = pending.stepId;
314
+ const review = { ...pending.payload, stepId, stage };
244
315
  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);
316
+ await this.claim(id, pending.id, consumerId, options.signal);
255
317
  }
256
318
  catch (error) {
257
- if (!options.signal?.aborted && !closed)
258
- await preparation
259
- .end({ status: "error" }, options.signal)
260
- .catch(() => { });
319
+ if (error instanceof ConstellationError && error.status === 409)
320
+ continue;
321
+ throw error;
322
+ }
323
+ await this.whileClaimed(id, pending.id, consumerId, true, async (activeSignal) => {
324
+ const reviewSpan = await this.startSpan(id, "caller.review", { stepId, inputId: pending.id, attributes: { stage } }, activeSignal);
325
+ let closed = false;
326
+ try {
327
+ const signal = activeSignal
328
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
329
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
330
+ const response = await bounded(callerReview(review, state, { inputId: pending.id, signal }), signal);
331
+ await reviewSpan.end({}, activeSignal);
332
+ closed = true;
333
+ await this.claim(id, pending.id, consumerId, activeSignal);
334
+ await this.respond(id, pending.id, { ...response, claimId: consumerId }, activeSignal);
335
+ }
336
+ catch (error) {
337
+ if (!activeSignal?.aborted && !closed)
338
+ await reviewSpan.end({ status: "error" }, activeSignal).catch(() => { });
339
+ throw error;
340
+ }
341
+ }, options.signal);
342
+ continue;
343
+ }
344
+ const userInput = options.userInput;
345
+ if (pending.kind === "user_input" && userInput) {
346
+ try {
347
+ await this.claim(id, pending.id, consumerId, options.signal);
348
+ }
349
+ catch (error) {
350
+ if (error instanceof ConstellationError && error.status === 409)
351
+ continue;
261
352
  throw error;
262
353
  }
354
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
355
+ const signal = activeSignal
356
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
357
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
358
+ const result = await bounded(userInput(state, { inputId: pending.id, signal }), signal);
359
+ if (state.request.kind === "agent")
360
+ await this.claim(id, pending.id, consumerId, activeSignal);
361
+ await this.respond(id, pending.id, { result, claimId: consumerId }, activeSignal);
362
+ }, options.signal);
263
363
  continue;
264
364
  }
265
365
  if (pending.kind !== "tool")
@@ -275,38 +375,34 @@ export class Constellation {
275
375
  continue;
276
376
  throw error;
277
377
  }
278
- const span = await this.startSpan(id, `tool.${pending.payload.name}`, {
279
- stepId: pending.stepId ?? undefined,
280
- attributes: { inputId: pending.id },
378
+ await this.whileClaimed(id, pending.id, consumerId, state.request.kind === "agent", async (activeSignal) => {
379
+ const span = await this.startSpan(id, `tool.${pending.payload.name}`, { stepId: pending.stepId ?? undefined, inputId: pending.id }, activeSignal);
380
+ let result;
381
+ let isError = false;
382
+ try {
383
+ if (pending.payload.validationError)
384
+ throw new Error(pending.payload.validationError);
385
+ if (!tool)
386
+ throw new Error("Tool unavailable");
387
+ const toolSignal = activeSignal
388
+ ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)])
389
+ : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
390
+ result = await bounded(tool(pending.payload.arguments ?? {}, {
391
+ inputId: pending.id,
392
+ requestId: id,
393
+ signal: toolSignal,
394
+ }), toolSignal);
395
+ }
396
+ catch (error) {
397
+ activeSignal?.throwIfAborted();
398
+ isError = true;
399
+ result = { error: error instanceof Error ? error.message : "Tool failed" };
400
+ }
401
+ await span.end({ status: isError ? "error" : "ok" }, activeSignal);
402
+ if (state.request.kind === "agent")
403
+ await this.claim(id, pending.id, consumerId, activeSignal);
404
+ await this.respond(id, pending.id, { result, isError, claimId: consumerId }, activeSignal);
281
405
  }, 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
406
  }
311
407
  await pause(500, options.signal);
312
408
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@lionhummer/constellation",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
+ "description": "TypeScript client for durable model requests and caller-controlled agent execution",
4
5
  "type": "module",
5
6
  "license": "UNLICENSED",
6
7
  "repository": {