@intx/agent 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.
@@ -0,0 +1,171 @@
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 ADDED
@@ -0,0 +1,93 @@
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
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import type { ReactorEmittedEvent } from "@intx/inference";
4
+
5
+ import { createStreamConsumer, StreamBackpressureError } from "./stream";
6
+
7
+ /**
8
+ * Build a minimal ReactorEmittedEvent suitable for fan-out testing. The
9
+ * event's structural details do not matter — the stream consumer treats
10
+ * events opaquely — so we use `reactor.done`, which has an empty `data`.
11
+ */
12
+ function makeEvent(seq: number): ReactorEmittedEvent {
13
+ return { type: "reactor.done", seq, data: {} };
14
+ }
15
+
16
+ async function collect(
17
+ it: AsyncIterableIterator<ReactorEmittedEvent>,
18
+ n: number,
19
+ ): Promise<ReactorEmittedEvent[]> {
20
+ const out: ReactorEmittedEvent[] = [];
21
+ for (let i = 0; i < n; i++) {
22
+ const r = await it.next();
23
+ if (r.done === true) break;
24
+ out.push(r.value);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ describe("createStreamConsumer", () => {
30
+ test("delivers buffered events to a later iterator read", async () => {
31
+ const c = createStreamConsumer(8);
32
+ const it = c.iterator();
33
+ c.push(makeEvent(1));
34
+ c.push(makeEvent(2));
35
+ const got = await collect(it, 2);
36
+ expect(got.map((e) => e.seq)).toEqual([1, 2]);
37
+ });
38
+
39
+ test("delivers events directly to a waiting iterator", async () => {
40
+ const c = createStreamConsumer(8);
41
+ const it = c.iterator();
42
+ const pending = it.next();
43
+ c.push(makeEvent(42));
44
+ const r = await pending;
45
+ expect(r.done).toBe(false);
46
+ if (r.done !== true) expect(r.value.seq).toBe(42);
47
+ });
48
+
49
+ test("close terminates pending and subsequent reads with done", async () => {
50
+ const c = createStreamConsumer(8);
51
+ const it = c.iterator();
52
+ const pending = it.next();
53
+ c.close();
54
+ const r1 = await pending;
55
+ expect(r1.done).toBe(true);
56
+ const r2 = await it.next();
57
+ expect(r2.done).toBe(true);
58
+ });
59
+
60
+ test("close after buffered events still drains them before done", async () => {
61
+ const c = createStreamConsumer(8);
62
+ const it = c.iterator();
63
+ c.push(makeEvent(1));
64
+ c.push(makeEvent(2));
65
+ c.close();
66
+ const r1 = await it.next();
67
+ expect(r1.done).toBe(false);
68
+ const r2 = await it.next();
69
+ expect(r2.done).toBe(false);
70
+ const r3 = await it.next();
71
+ expect(r3.done).toBe(true);
72
+ });
73
+
74
+ test("overflow throws StreamBackpressureError on next read", async () => {
75
+ const c = createStreamConsumer(3);
76
+ const it = c.iterator();
77
+ c.push(makeEvent(1));
78
+ c.push(makeEvent(2));
79
+ c.push(makeEvent(3));
80
+ c.push(makeEvent(4));
81
+
82
+ // Buffered events drain first.
83
+ const r1 = await it.next();
84
+ expect(r1.done).toBe(false);
85
+ const r2 = await it.next();
86
+ expect(r2.done).toBe(false);
87
+ const r3 = await it.next();
88
+ expect(r3.done).toBe(false);
89
+
90
+ // Next read sees the overflow.
91
+ await expect(it.next()).rejects.toBeInstanceOf(StreamBackpressureError);
92
+ });
93
+
94
+ test("overflow rejects a pending waiter", async () => {
95
+ const c = createStreamConsumer(2);
96
+ const it = c.iterator();
97
+ const pending = it.next();
98
+ // Direct delivery to the waiter does NOT increase the buffer.
99
+ c.push(makeEvent(1));
100
+ const r1 = await pending;
101
+ expect(r1.done).toBe(false);
102
+
103
+ // Now buffer 2 events (capacity), then a 3rd while another waiter is
104
+ // pending — wait, an immediate waiter would consume the 3rd directly.
105
+ // Instead saturate the buffer first.
106
+ c.push(makeEvent(2));
107
+ c.push(makeEvent(3));
108
+ // Saturated. A pending waiter at this point will be served from the
109
+ // buffer; the overflow only fires on a push that has no waiter and a
110
+ // full buffer.
111
+ c.push(makeEvent(4));
112
+
113
+ // Drain.
114
+ const r2 = await it.next();
115
+ expect(r2.done).toBe(false);
116
+ const r3 = await it.next();
117
+ expect(r3.done).toBe(false);
118
+
119
+ await expect(it.next()).rejects.toBeInstanceOf(StreamBackpressureError);
120
+ });
121
+
122
+ test("multiple consumers buffer independently", async () => {
123
+ const a = createStreamConsumer(8);
124
+ const b = createStreamConsumer(8);
125
+ const ai = a.iterator();
126
+ const bi = b.iterator();
127
+
128
+ a.push(makeEvent(1));
129
+ b.push(makeEvent(1));
130
+ a.push(makeEvent(2));
131
+ b.push(makeEvent(2));
132
+
133
+ const aGot = await collect(ai, 2);
134
+ const bGot = await collect(bi, 2);
135
+ expect(aGot.map((e) => e.seq)).toEqual([1, 2]);
136
+ expect(bGot.map((e) => e.seq)).toEqual([1, 2]);
137
+ });
138
+
139
+ test("iterator.return() closes the consumer", async () => {
140
+ const c = createStreamConsumer(8);
141
+ const it = c.iterator();
142
+ expect(c.closed).toBe(false);
143
+ await it.return?.();
144
+ expect(c.closed).toBe(true);
145
+ const r = await it.next();
146
+ expect(r.done).toBe(true);
147
+ });
148
+
149
+ test("push after close is ignored", async () => {
150
+ const c = createStreamConsumer(8);
151
+ const it = c.iterator();
152
+ c.close();
153
+ c.push(makeEvent(1));
154
+ const r = await it.next();
155
+ expect(r.done).toBe(true);
156
+ });
157
+
158
+ test("Symbol.asyncIterator returns the iterator itself", async () => {
159
+ const c = createStreamConsumer(8);
160
+ const it = c.iterator();
161
+ expect(it[Symbol.asyncIterator]()).toBe(it);
162
+ });
163
+
164
+ test("rejects maxBuffer < 1", () => {
165
+ expect(() => createStreamConsumer(0)).toThrow();
166
+ });
167
+ });
package/src/stream.ts ADDED
@@ -0,0 +1,142 @@
1
+ // Bounded per-consumer fan-out for the agent's reactor event stream.
2
+ //
3
+ // Each call to `agent.stream()` creates a fresh `StreamConsumer`. The
4
+ // agent feeds every reactor event to every consumer; consumers buffer
5
+ // independently. If a consumer falls more than `maxBuffer` events behind
6
+ // it is poisoned with `StreamBackpressureError` and its iterator throws
7
+ // on the next read — the consumer is removed but other consumers keep
8
+ // running.
9
+ //
10
+ // Loud failure matches the defensive-coding rule: silently dropping
11
+ // events would hide consumer bugs, and unbounded buffering would let a
12
+ // stalled consumer balloon the agent's memory. The cap is configurable
13
+ // via `streamBufferMax` on `AgentConfig`.
14
+
15
+ import type { ReactorEmittedEvent } from "@intx/inference";
16
+
17
+ export class StreamBackpressureError extends Error {
18
+ readonly maxBuffer: number;
19
+
20
+ constructor(maxBuffer: number) {
21
+ super(`stream consumer fell more than ${String(maxBuffer)} events behind`);
22
+ this.name = "StreamBackpressureError";
23
+ this.maxBuffer = maxBuffer;
24
+ }
25
+ }
26
+
27
+ type Waiter = {
28
+ resolve: (value: IteratorResult<ReactorEmittedEvent>) => void;
29
+ reject: (reason: unknown) => void;
30
+ };
31
+
32
+ export type StreamConsumer = {
33
+ /** Deliver an event to this consumer's buffer. */
34
+ push(event: ReactorEmittedEvent): void;
35
+ /** Cleanly terminate the iterator with `done: true`. */
36
+ close(): void;
37
+ /** True once close() or an overflow has poisoned the consumer. */
38
+ readonly closed: boolean;
39
+ /** Iterator handed back to the caller of `stream()`. */
40
+ iterator(): AsyncIterableIterator<ReactorEmittedEvent>;
41
+ };
42
+
43
+ export function createStreamConsumer(maxBuffer: number): StreamConsumer {
44
+ if (maxBuffer < 1) {
45
+ throw new Error(`streamBufferMax must be >= 1, got ${String(maxBuffer)}`);
46
+ }
47
+
48
+ const buffer: ReactorEmittedEvent[] = [];
49
+ const waiters: Waiter[] = [];
50
+ let overflow: StreamBackpressureError | undefined;
51
+ let done = false;
52
+
53
+ function settleOverflowedWaiters(err: StreamBackpressureError): void {
54
+ while (waiters.length > 0) {
55
+ const w = waiters.shift();
56
+ if (w === undefined) return;
57
+ w.reject(err);
58
+ }
59
+ }
60
+
61
+ function settleDoneWaiters(): void {
62
+ while (waiters.length > 0) {
63
+ const w = waiters.shift();
64
+ if (w === undefined) return;
65
+ w.resolve({ value: undefined, done: true });
66
+ }
67
+ }
68
+
69
+ function push(event: ReactorEmittedEvent): void {
70
+ if (done || overflow !== undefined) return;
71
+
72
+ if (waiters.length > 0) {
73
+ const w = waiters.shift();
74
+ if (w === undefined) return;
75
+ w.resolve({ value: event, done: false });
76
+ return;
77
+ }
78
+
79
+ if (buffer.length >= maxBuffer) {
80
+ overflow = new StreamBackpressureError(maxBuffer);
81
+ settleOverflowedWaiters(overflow);
82
+ return;
83
+ }
84
+
85
+ buffer.push(event);
86
+ }
87
+
88
+ function close(): void {
89
+ if (done) return;
90
+ done = true;
91
+ settleDoneWaiters();
92
+ }
93
+
94
+ function nextResult(): Promise<IteratorResult<ReactorEmittedEvent>> {
95
+ if (overflow !== undefined) {
96
+ // Drain any buffered events before throwing so the caller sees
97
+ // every event up to the overflow point.
98
+ if (buffer.length > 0) {
99
+ const ev = buffer.shift();
100
+ if (ev !== undefined) {
101
+ return Promise.resolve({ value: ev, done: false });
102
+ }
103
+ }
104
+ return Promise.reject(overflow);
105
+ }
106
+ if (buffer.length > 0) {
107
+ const ev = buffer.shift();
108
+ if (ev !== undefined) {
109
+ return Promise.resolve({ value: ev, done: false });
110
+ }
111
+ }
112
+ if (done) {
113
+ return Promise.resolve({ value: undefined, done: true });
114
+ }
115
+ return new Promise((resolve, reject) => {
116
+ waiters.push({ resolve, reject });
117
+ });
118
+ }
119
+
120
+ function iterator(): AsyncIterableIterator<ReactorEmittedEvent> {
121
+ const it: AsyncIterableIterator<ReactorEmittedEvent> = {
122
+ next: nextResult,
123
+ async return() {
124
+ close();
125
+ return { value: undefined, done: true };
126
+ },
127
+ [Symbol.asyncIterator]() {
128
+ return it;
129
+ },
130
+ };
131
+ return it;
132
+ }
133
+
134
+ return {
135
+ push,
136
+ close,
137
+ get closed() {
138
+ return done || overflow !== undefined;
139
+ },
140
+ iterator,
141
+ };
142
+ }