@intx/agent 0.1.2 → 0.3.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.
Files changed (60) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +80 -5
  3. package/dist/agent.d.ts +116 -0
  4. package/dist/agent.js +682 -0
  5. package/dist/canonicalize.d.ts +15 -0
  6. package/dist/canonicalize.js +160 -0
  7. package/dist/default-director.d.ts +24 -0
  8. package/dist/default-director.js +45 -0
  9. package/dist/definition.d.ts +139 -0
  10. package/dist/definition.js +40 -0
  11. package/dist/director-registry.d.ts +47 -0
  12. package/dist/director-registry.js +87 -0
  13. package/dist/director-types.d.ts +80 -0
  14. package/dist/director-types.js +13 -0
  15. package/dist/director.d.ts +70 -0
  16. package/dist/director.js +131 -0
  17. package/dist/env-validation.d.ts +59 -0
  18. package/dist/env-validation.js +180 -0
  19. package/dist/env.d.ts +160 -0
  20. package/dist/env.js +53 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +23 -0
  23. package/dist/internal-fixtures/mail.d.ts +39 -0
  24. package/dist/internal-fixtures/mail.js +86 -0
  25. package/dist/internal-fixtures/planner.d.ts +19 -0
  26. package/dist/internal-fixtures/planner.js +49 -0
  27. package/dist/lock.d.ts +16 -0
  28. package/dist/lock.js +47 -0
  29. package/dist/namespace.d.ts +12 -0
  30. package/dist/namespace.js +39 -0
  31. package/dist/send-queue.d.ts +25 -0
  32. package/dist/send-queue.js +147 -0
  33. package/dist/source.d.ts +43 -0
  34. package/dist/source.js +118 -0
  35. package/dist/stream.d.ts +16 -0
  36. package/dist/stream.js +115 -0
  37. package/dist/testing/audit-noop.d.ts +7 -0
  38. package/dist/testing/audit-noop.js +25 -0
  39. package/dist/testing/authorize-allow.d.ts +8 -0
  40. package/dist/testing/authorize-allow.js +19 -0
  41. package/dist/testing/index.d.ts +2 -0
  42. package/dist/testing/index.js +17 -0
  43. package/dist/tool.d.ts +238 -0
  44. package/dist/tool.js +244 -0
  45. package/package.json +26 -7
  46. package/src/agent.test.ts +0 -46
  47. package/src/agent.ts +0 -494
  48. package/src/index.ts +0 -38
  49. package/src/lock.test.ts +0 -93
  50. package/src/lock.ts +0 -57
  51. package/src/send-queue.test.ts +0 -207
  52. package/src/send-queue.ts +0 -200
  53. package/src/source.test.ts +0 -171
  54. package/src/source.ts +0 -93
  55. package/src/stream.test.ts +0 -167
  56. package/src/stream.ts +0 -142
  57. package/src/tool.test.ts +0 -217
  58. package/src/tool.ts +0 -148
  59. package/tsconfig.json +0 -4
  60. package/tsconfig.tsbuildinfo +0 -1
@@ -1,207 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
-
3
- import { createSendQueue, SendQueueFullError } from "./send-queue";
4
-
5
- /**
6
- * Tests use `string` as the result type so the queue's generic R parameter
7
- * holds a real value (it cannot be `void` under
8
- * `@typescript-eslint/no-invalid-void-type`). The actual values are
9
- * arbitrary; tests assert ordering and lifecycle, not result content.
10
- */
11
-
12
- describe("createSendQueue", () => {
13
- test("starts the first enqueued item immediately", () => {
14
- const started: string[] = [];
15
- const q = createSendQueue<string, string>({
16
- maxDepth: 4,
17
- start: (item) => {
18
- started.push(item);
19
- },
20
- });
21
-
22
- void q.enqueue("a");
23
- expect(started).toEqual(["a"]);
24
- });
25
-
26
- test("queues subsequent items and starts each on resolveActive", async () => {
27
- const started: string[] = [];
28
- const q = createSendQueue<string, string>({
29
- maxDepth: 4,
30
- start: (item) => {
31
- started.push(item);
32
- },
33
- });
34
-
35
- const p1 = q.enqueue("a");
36
- const p2 = q.enqueue("b");
37
- expect(started).toEqual(["a"]);
38
-
39
- q.resolveActive("r1");
40
- expect(await p1).toBe("r1");
41
- expect(started).toEqual(["a", "b"]);
42
-
43
- q.resolveActive("r2");
44
- expect(await p2).toBe("r2");
45
- });
46
-
47
- test("throws SendQueueFullError synchronously at capacity", () => {
48
- const started: number[] = [];
49
- const q = createSendQueue<number, string>({
50
- maxDepth: 2,
51
- start: (item) => {
52
- started.push(item);
53
- },
54
- });
55
-
56
- void q.enqueue(1);
57
- void q.enqueue(2);
58
- expect(() => q.enqueue(3)).toThrow(SendQueueFullError);
59
- });
60
-
61
- test("rejects pre-aborted signal without enqueuing", async () => {
62
- const ctl = new AbortController();
63
- ctl.abort();
64
-
65
- const started: number[] = [];
66
- const q = createSendQueue<number, string>({
67
- maxDepth: 4,
68
- start: (item) => {
69
- started.push(item);
70
- },
71
- });
72
-
73
- await expect(q.enqueue(1, ctl.signal)).rejects.toBeDefined();
74
- expect(started).toEqual([]);
75
- expect(q.depth).toBe(0);
76
- });
77
-
78
- test("removes a queued item whose signal fires before processing", async () => {
79
- const started: number[] = [];
80
- const ctl = new AbortController();
81
- const q = createSendQueue<number, string>({
82
- maxDepth: 4,
83
- start: (item) => {
84
- started.push(item);
85
- },
86
- });
87
-
88
- const p1 = q.enqueue(1);
89
- const p2 = q.enqueue(2, ctl.signal);
90
- const p3 = q.enqueue(3);
91
-
92
- expect(started).toEqual([1]);
93
- ctl.abort();
94
- await expect(p2).rejects.toBeDefined();
95
-
96
- q.resolveActive("r1");
97
- expect(await p1).toBe("r1");
98
- expect(started).toEqual([1, 3]);
99
-
100
- q.resolveActive("r3");
101
- expect(await p3).toBe("r3");
102
- });
103
-
104
- test("settles caller on in-flight abort but waits for consumer to advance", async () => {
105
- const started: number[] = [];
106
- const ctl = new AbortController();
107
- const q = createSendQueue<number, string>({
108
- maxDepth: 4,
109
- start: (item) => {
110
- started.push(item);
111
- },
112
- });
113
-
114
- const p1 = q.enqueue(1, ctl.signal);
115
- const p2 = q.enqueue(2);
116
- expect(started).toEqual([1]);
117
-
118
- ctl.abort();
119
- await expect(p1).rejects.toBeDefined();
120
- // Active slot is still held until the consumer reports the cycle done.
121
- expect(started).toEqual([1]);
122
-
123
- q.resolveActive("late-r1");
124
- expect(started).toEqual([1, 2]);
125
-
126
- q.resolveActive("r2");
127
- expect(await p2).toBe("r2");
128
- });
129
-
130
- test("late resolveActive after abort is a no-op for the caller", async () => {
131
- const ctl = new AbortController();
132
- const started: number[] = [];
133
- const q = createSendQueue<number, string>({
134
- maxDepth: 4,
135
- start: (item) => {
136
- started.push(item);
137
- },
138
- });
139
-
140
- const p = q.enqueue(1, ctl.signal);
141
- ctl.abort();
142
- await expect(p).rejects.toBeDefined();
143
-
144
- q.resolveActive("late");
145
- expect(q.depth).toBe(0);
146
- });
147
-
148
- test("drain rejects active and pending jobs with the given reason", async () => {
149
- const started: number[] = [];
150
- const q = createSendQueue<number, string>({
151
- maxDepth: 4,
152
- start: (item) => {
153
- started.push(item);
154
- },
155
- });
156
-
157
- const p1 = q.enqueue(1);
158
- const p2 = q.enqueue(2);
159
- const reason = new Error("closed");
160
-
161
- q.drain(reason);
162
- await expect(p1).rejects.toBe(reason);
163
- await expect(p2).rejects.toBe(reason);
164
- expect(q.depth).toBe(0);
165
- });
166
-
167
- test("depth reflects active + pending", () => {
168
- const started: number[] = [];
169
- const q = createSendQueue<number, string>({
170
- maxDepth: 4,
171
- start: (item) => {
172
- started.push(item);
173
- },
174
- });
175
-
176
- expect(q.depth).toBe(0);
177
- void q.enqueue(1);
178
- expect(q.depth).toBe(1);
179
- void q.enqueue(2);
180
- expect(q.depth).toBe(2);
181
-
182
- q.resolveActive("r1");
183
- expect(q.depth).toBe(1);
184
- q.resolveActive("r2");
185
- expect(q.depth).toBe(0);
186
- });
187
-
188
- test("capacity check counts the abandoned active slot", async () => {
189
- const ctl = new AbortController();
190
- const started: number[] = [];
191
- const q = createSendQueue<number, string>({
192
- maxDepth: 2,
193
- start: (item) => {
194
- started.push(item);
195
- },
196
- });
197
-
198
- const p1 = q.enqueue(1, ctl.signal);
199
- void q.enqueue(2);
200
- ctl.abort();
201
- await expect(p1).rejects.toBeDefined();
202
-
203
- // Active slot still occupied (consumer has not advanced). Adding a
204
- // third would exceed maxDepth=2.
205
- expect(() => q.enqueue(3)).toThrow(SendQueueFullError);
206
- });
207
- });
package/src/send-queue.ts DELETED
@@ -1,200 +0,0 @@
1
- // FIFO queue for serializing send() calls against a single reactor.
2
- //
3
- // The agent processes one reactor cycle at a time, so concurrent send()
4
- // callers are queued. Each queued item carries the caller's resolve/reject
5
- // hooks and an optional AbortSignal:
6
- //
7
- // - If the signal is already aborted when enqueue() is called the queue
8
- // rejects synchronously without enqueueing.
9
- // - If the signal fires while the item is still queued the item is
10
- // removed and rejected.
11
- // - If the signal fires while the item is active the caller-facing
12
- // promise rejects immediately, but the reactor cycle continues in the
13
- // background. The queue does not start the next item until the consumer
14
- // reports the cycle done via resolveActive/rejectActive. This keeps the
15
- // queue ordered against actual reactor cycles — two send() promises
16
- // cannot interleave at the reactor level.
17
- //
18
- // Queue depth (active + pending) is bounded by `maxDepth`; exceeding it
19
- // throws `SendQueueFullError` synchronously from enqueue() so a buggy
20
- // caller flooding sends fails loud instead of silently buffering.
21
-
22
- export class SendQueueFullError extends Error {
23
- readonly maxDepth: number;
24
-
25
- constructor(maxDepth: number) {
26
- super(`send queue is full (max depth ${String(maxDepth)})`);
27
- this.name = "SendQueueFullError";
28
- this.maxDepth = maxDepth;
29
- }
30
- }
31
-
32
- type Job<T, R> = {
33
- item: T;
34
- signal?: AbortSignal;
35
- abortHandler?: () => void;
36
- resolve: (value: R) => void;
37
- reject: (reason: unknown) => void;
38
- /**
39
- * True once the caller-facing promise has been settled (resolve or
40
- * reject). Subsequent settles are no-ops. The active slot may remain
41
- * occupied after a settle when the caller aborted mid-cycle — the queue
42
- * waits for the consumer's resolveActive/rejectActive before pumping the
43
- * next item.
44
- */
45
- settled: boolean;
46
- };
47
-
48
- export type SendQueueOptions<T> = {
49
- maxDepth: number;
50
- /**
51
- * Called when a job moves from pending to active. The consumer drives
52
- * the underlying work and must eventually call `resolveActive` or
53
- * `rejectActive` exactly once.
54
- */
55
- start: (item: T) => void;
56
- };
57
-
58
- export type SendQueue<T, R> = {
59
- enqueue(item: T, signal?: AbortSignal): Promise<R>;
60
- /** Mark the active job complete with success and pump the next. */
61
- resolveActive(value: R): void;
62
- /** Mark the active job complete with failure and pump the next. */
63
- rejectActive(reason: unknown): void;
64
- /** Reject the active job (if any) and every pending job with `reason`. */
65
- drain(reason: unknown): void;
66
- /** Current pending count (queued + active). */
67
- readonly depth: number;
68
- };
69
-
70
- function abortReason(signal: AbortSignal): unknown {
71
- return signal.reason ?? new DOMException("aborted", "AbortError");
72
- }
73
-
74
- export function createSendQueue<T, R>(
75
- opts: SendQueueOptions<T>,
76
- ): SendQueue<T, R> {
77
- const pending: Job<T, R>[] = [];
78
- let active: Job<T, R> | null = null;
79
-
80
- function settle(
81
- job: Job<T, R>,
82
- kind: "resolve" | "reject",
83
- value: unknown,
84
- ): void {
85
- if (job.settled) return;
86
- job.settled = true;
87
- if (job.abortHandler !== undefined && job.signal !== undefined) {
88
- job.signal.removeEventListener("abort", job.abortHandler);
89
- }
90
- if (kind === "resolve") {
91
- // The queue's value type is checked at enqueue / resolveActive; the
92
- // generic narrowing here is safe by construction.
93
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic resolve value
94
- job.resolve(value as R);
95
- } else {
96
- job.reject(value);
97
- }
98
- }
99
-
100
- function pump(): void {
101
- while (active === null && pending.length > 0) {
102
- const next = pending.shift();
103
- if (next === undefined) return;
104
- if (next.signal?.aborted === true) {
105
- settle(next, "reject", abortReason(next.signal));
106
- continue;
107
- }
108
- active = next;
109
- opts.start(next.item);
110
- return;
111
- }
112
- }
113
-
114
- function enqueue(item: T, signal?: AbortSignal): Promise<R> {
115
- if (signal?.aborted === true) {
116
- return Promise.reject(abortReason(signal));
117
- }
118
-
119
- const depth = pending.length + (active !== null ? 1 : 0);
120
- if (depth >= opts.maxDepth) {
121
- throw new SendQueueFullError(opts.maxDepth);
122
- }
123
-
124
- let resolve!: (value: R) => void;
125
- let reject!: (reason: unknown) => void;
126
- const promise = new Promise<R>((res, rej) => {
127
- resolve = res;
128
- reject = rej;
129
- });
130
-
131
- const job: Job<T, R> = {
132
- item,
133
- ...(signal !== undefined ? { signal } : {}),
134
- resolve,
135
- reject,
136
- settled: false,
137
- };
138
-
139
- if (signal !== undefined) {
140
- const handler = (): void => {
141
- const reason = abortReason(signal);
142
- if (active === job) {
143
- // In flight: settle the caller now; the consumer will eventually
144
- // call resolveActive/rejectActive which becomes a no-op and
145
- // advances the queue.
146
- settle(job, "reject", reason);
147
- } else {
148
- const idx = pending.indexOf(job);
149
- if (idx >= 0) pending.splice(idx, 1);
150
- settle(job, "reject", reason);
151
- }
152
- };
153
- signal.addEventListener("abort", handler, { once: true });
154
- job.abortHandler = handler;
155
- }
156
-
157
- pending.push(job);
158
- pump();
159
- return promise;
160
- }
161
-
162
- function resolveActive(value: R): void {
163
- if (active === null) return;
164
- const job = active;
165
- active = null;
166
- settle(job, "resolve", value);
167
- pump();
168
- }
169
-
170
- function rejectActive(reason: unknown): void {
171
- if (active === null) return;
172
- const job = active;
173
- active = null;
174
- settle(job, "reject", reason);
175
- pump();
176
- }
177
-
178
- function drain(reason: unknown): void {
179
- const drained: Job<T, R>[] = [];
180
- if (active !== null) {
181
- drained.push(active);
182
- active = null;
183
- }
184
- drained.push(...pending);
185
- pending.length = 0;
186
- for (const job of drained) {
187
- settle(job, "reject", reason);
188
- }
189
- }
190
-
191
- return {
192
- enqueue,
193
- resolveActive,
194
- rejectActive,
195
- drain,
196
- get depth() {
197
- return pending.length + (active !== null ? 1 : 0);
198
- },
199
- };
200
- }
@@ -1,171 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
-
3
- import type { InferenceSource } from "@intx/types/runtime";
4
-
5
- import {
6
- createSourceRegistry,
7
- InvalidInferenceSourceError,
8
- SourceNotFoundError,
9
- } from "./source";
10
-
11
- const S_ANTHROPIC: InferenceSource = {
12
- id: "anthropic:claude-3-5-sonnet",
13
- provider: "anthropic",
14
- baseURL: "https://api.anthropic.com",
15
- apiKey: "sk-anthropic-1",
16
- model: "claude-3-5-sonnet",
17
- };
18
-
19
- const S_OPENAI: InferenceSource = {
20
- id: "openai:gpt-4o",
21
- provider: "openai",
22
- baseURL: "https://api.openai.com",
23
- apiKey: "sk-openai-1",
24
- model: "gpt-4o",
25
- };
26
-
27
- /**
28
- * Test helper: produce an intentionally-invalid `InferenceSource` value so
29
- * we can verify that runtime validation rejects it. The whole point is to
30
- * exercise the arktype check, which requires bypassing the static type.
31
- */
32
- function invalidSource(value: unknown): InferenceSource {
33
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentional invalid input for arktype validation test
34
- return value as InferenceSource;
35
- }
36
-
37
- describe("createSourceRegistry", () => {
38
- test("selects the source whose id matches defaultSource", () => {
39
- const reg = createSourceRegistry({
40
- sources: [S_ANTHROPIC, S_OPENAI],
41
- defaultSource: "openai:gpt-4o",
42
- });
43
- expect(reg.active.provider).toBe("openai");
44
- expect(reg.active.model).toBe("gpt-4o");
45
- expect(reg.active.apiKey).toBe("sk-openai-1");
46
- });
47
-
48
- test("rejects an empty sources[] array", () => {
49
- expect(() =>
50
- createSourceRegistry({ sources: [], defaultSource: "anything" }),
51
- ).toThrow(InvalidInferenceSourceError);
52
- });
53
-
54
- test("rejects sources that fail InferenceSource arktype validation", () => {
55
- expect(() =>
56
- createSourceRegistry({
57
- sources: [invalidSource({ id: "x", provider: "x", apiKey: "k" })],
58
- defaultSource: "anything",
59
- }),
60
- ).toThrow(InvalidInferenceSourceError);
61
- });
62
-
63
- test("rejects a source entry missing model", () => {
64
- const noModel = invalidSource({
65
- id: "anthropic:none",
66
- provider: "anthropic",
67
- baseURL: "u",
68
- apiKey: "k",
69
- });
70
- expect(() =>
71
- createSourceRegistry({
72
- sources: [noModel],
73
- defaultSource: "anthropic:none",
74
- }),
75
- ).toThrow(InvalidInferenceSourceError);
76
- });
77
-
78
- test("rejects sources[] with duplicate ids", () => {
79
- expect(() =>
80
- createSourceRegistry({
81
- sources: [S_ANTHROPIC, { ...S_ANTHROPIC, apiKey: "sk-other" }],
82
- defaultSource: S_ANTHROPIC.id,
83
- }),
84
- ).toThrow(InvalidInferenceSourceError);
85
- });
86
-
87
- test("throws SourceNotFoundError when defaultSource matches no source", () => {
88
- expect(() =>
89
- createSourceRegistry({
90
- sources: [S_ANTHROPIC],
91
- defaultSource: "openai:gpt-4o",
92
- }),
93
- ).toThrow(SourceNotFoundError);
94
- });
95
-
96
- test("active is a mutable holder; setSource mutates fields in place", () => {
97
- const reg = createSourceRegistry({
98
- sources: [S_ANTHROPIC],
99
- defaultSource: "anthropic:claude-3-5-sonnet",
100
- });
101
- const reference = reg.active;
102
-
103
- reg.setSource({
104
- id: "anthropic:claude-3-5-haiku",
105
- provider: "anthropic",
106
- baseURL: "https://proxy.example.com",
107
- apiKey: "sk-new",
108
- model: "claude-3-5-haiku",
109
- });
110
-
111
- expect(reg.active).toBe(reference);
112
- expect(reg.active.id).toBe("anthropic:claude-3-5-haiku");
113
- expect(reg.active.provider).toBe("anthropic");
114
- expect(reg.active.baseURL).toBe("https://proxy.example.com");
115
- expect(reg.active.apiKey).toBe("sk-new");
116
- expect(reg.active.model).toBe("claude-3-5-haiku");
117
- });
118
-
119
- test("setSource overwrites defaults and capabilities, including deletion", () => {
120
- const reg = createSourceRegistry({
121
- sources: [
122
- {
123
- ...S_ANTHROPIC,
124
- defaults: { maxTokens: 1024 },
125
- capabilities: ["text"],
126
- },
127
- ],
128
- defaultSource: S_ANTHROPIC.id,
129
- });
130
-
131
- reg.setSource({
132
- ...S_ANTHROPIC,
133
- defaults: { maxTokens: 4096 },
134
- capabilities: ["text", "vision"],
135
- });
136
- expect(reg.active.defaults).toEqual({ maxTokens: 4096 });
137
- expect(reg.active.capabilities).toEqual(["text", "vision"]);
138
-
139
- reg.setSource(S_ANTHROPIC);
140
- expect(reg.active.defaults).toBeUndefined();
141
- expect(reg.active.capabilities).toBeUndefined();
142
- });
143
-
144
- test("setSource throws InvalidInferenceSourceError on invalid input", () => {
145
- const reg = createSourceRegistry({
146
- sources: [S_ANTHROPIC],
147
- defaultSource: S_ANTHROPIC.id,
148
- });
149
-
150
- expect(() => reg.setSource(invalidSource({ provider: "x" }))).toThrow(
151
- InvalidInferenceSourceError,
152
- );
153
- });
154
-
155
- test("does not mutate the caller's sources[] entries", () => {
156
- const inputs: InferenceSource[] = [{ ...S_ANTHROPIC }];
157
- const reg = createSourceRegistry({
158
- sources: inputs,
159
- defaultSource: S_ANTHROPIC.id,
160
- });
161
-
162
- reg.setSource({
163
- ...S_ANTHROPIC,
164
- baseURL: "https://other.example.com",
165
- apiKey: "sk-other",
166
- });
167
-
168
- expect(inputs[0]?.apiKey).toBe("sk-anthropic-1");
169
- expect(inputs[0]?.baseURL).toBe("https://api.anthropic.com");
170
- });
171
- });
package/src/source.ts DELETED
@@ -1,93 +0,0 @@
1
- // Inference source registry.
2
- //
3
- // The agent accepts an array of pre-configured inference sources and a
4
- // `defaultSource` id at construction. The source whose `id` matches
5
- // `defaultSource` becomes the active source — the same object reference
6
- // is what the reactor's assembly holds and reads lazily at each
7
- // inference call.
8
- //
9
- // `setSource` mutates that shared object in place so the next inference
10
- // call observes the new credentials, model, and bound defaults. In-flight
11
- // calls keep using the values they read at start-of-call (the reactor
12
- // does not refetch mid-stream); the swap is therefore safe with respect
13
- // to torn state.
14
-
15
- import { type } from "arktype";
16
-
17
- import {
18
- InferenceSource as InferenceSourceValidator,
19
- applyInferenceSourceFields,
20
- type InferenceSource,
21
- } from "@intx/types/runtime";
22
-
23
- export class InvalidInferenceSourceError extends Error {
24
- constructor(message: string) {
25
- super(message);
26
- this.name = "InvalidInferenceSourceError";
27
- }
28
- }
29
-
30
- export class SourceNotFoundError extends Error {
31
- readonly id: string;
32
-
33
- constructor(id: string) {
34
- super(`no source in sources[] has id ${id}`);
35
- this.name = "SourceNotFoundError";
36
- this.id = id;
37
- }
38
- }
39
-
40
- export type SourceRegistry = {
41
- /**
42
- * The mutable active source. The same object reference is held by the
43
- * reactor; mutating it through `setSource` is what swaps the source for
44
- * subsequent inference calls.
45
- */
46
- readonly active: InferenceSource;
47
- /** Replace the active source's fields in place. */
48
- setSource(source: InferenceSource): void;
49
- };
50
-
51
- export function createSourceRegistry(opts: {
52
- sources: InferenceSource[];
53
- defaultSource: string;
54
- }): SourceRegistry {
55
- if (opts.sources.length === 0) {
56
- throw new InvalidInferenceSourceError("sources[] must be non-empty");
57
- }
58
-
59
- const validated: InferenceSource[] = [];
60
- const seenIds = new Set<string>();
61
- for (const [i, raw] of opts.sources.entries()) {
62
- const parsed = InferenceSourceValidator(raw);
63
- if (parsed instanceof type.errors) {
64
- throw new InvalidInferenceSourceError(
65
- `sources[${String(i)}]: ${parsed.summary}`,
66
- );
67
- }
68
- if (seenIds.has(parsed.id)) {
69
- throw new InvalidInferenceSourceError(
70
- `sources[${String(i)}]: duplicate id ${parsed.id}`,
71
- );
72
- }
73
- seenIds.add(parsed.id);
74
- validated.push(parsed);
75
- }
76
-
77
- const initial = validated.find((s) => s.id === opts.defaultSource);
78
- if (initial === undefined) {
79
- throw new SourceNotFoundError(opts.defaultSource);
80
- }
81
-
82
- const active: InferenceSource = { ...initial };
83
-
84
- function setSource(source: InferenceSource): void {
85
- const parsed = InferenceSourceValidator(source);
86
- if (parsed instanceof type.errors) {
87
- throw new InvalidInferenceSourceError(parsed.summary);
88
- }
89
- applyInferenceSourceFields(active, parsed);
90
- }
91
-
92
- return { active, setSource };
93
- }