@positronic/core 0.0.2 → 0.0.4

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.
@@ -1,384 +0,0 @@
1
- import { BrainRunner } from './brain-runner.js';
2
- import { brain, type SerializedStep } from './brain.js';
3
- import { BRAIN_EVENTS, STATUS } from './constants.js';
4
- import { jest, describe, it, expect, beforeEach } from '@jest/globals';
5
- import { ObjectGenerator } from '../clients/types.js';
6
- import { Adapter } from '../adapters/types.js';
7
- import { createResources, type Resources } from '../resources/resources.js';
8
- import type { ResourceLoader } from '../resources/resource-loader.js';
9
- import { z } from 'zod';
10
-
11
- describe('BrainRunner', () => {
12
- const mockGenerateObject = jest.fn<ObjectGenerator['generateObject']>();
13
- const mockClient: jest.Mocked<ObjectGenerator> = {
14
- generateObject: mockGenerateObject,
15
- };
16
-
17
- const mockDispatch = jest.fn<Adapter['dispatch']>();
18
- const mockAdapter: jest.Mocked<Adapter> = {
19
- dispatch: mockDispatch,
20
- };
21
-
22
- beforeEach(() => {
23
- jest.clearAllMocks();
24
- });
25
-
26
- it('should run a brain and dispatch events to adapters', async () => {
27
- const runner = new BrainRunner({
28
- adapters: [mockAdapter],
29
- client: mockClient,
30
- });
31
-
32
- const testBrain = brain('Test Brain')
33
- .step('First Step', () => ({ value: 42 }))
34
- .step('Async Step', async ({ state }) => {
35
- await new Promise((resolve) => setTimeout(resolve, 10));
36
- return { ...state, asyncValue: 'completed' };
37
- })
38
- .step('Final Step', ({ state }) => ({
39
- ...state,
40
- finalValue: state.value * 2,
41
- }));
42
-
43
- await runner.run(testBrain);
44
-
45
- // Verify adapter received all events in correct order
46
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
47
- expect.objectContaining({
48
- type: BRAIN_EVENTS.START,
49
- brainTitle: 'Test Brain',
50
- })
51
- );
52
-
53
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
54
- expect.objectContaining({
55
- type: BRAIN_EVENTS.STEP_COMPLETE,
56
- stepTitle: 'First Step',
57
- })
58
- );
59
-
60
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
61
- expect.objectContaining({
62
- type: BRAIN_EVENTS.STEP_COMPLETE,
63
- stepTitle: 'Async Step',
64
- })
65
- );
66
-
67
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
68
- expect.objectContaining({
69
- type: BRAIN_EVENTS.STEP_COMPLETE,
70
- stepTitle: 'Final Step',
71
- })
72
- );
73
-
74
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
75
- expect.objectContaining({
76
- type: BRAIN_EVENTS.COMPLETE,
77
- status: STATUS.COMPLETE,
78
- })
79
- );
80
-
81
- // Verify the order of events
82
- const stepCompletions = mockAdapter.dispatch.mock.calls
83
- .filter((call) => (call[0] as any).type === BRAIN_EVENTS.STEP_COMPLETE)
84
- .map((call) => (call[0] as any).stepTitle);
85
-
86
- expect(stepCompletions).toEqual(['First Step', 'Async Step', 'Final Step']);
87
- });
88
-
89
- it('should handle brain errors', async () => {
90
- const runner = new BrainRunner({
91
- adapters: [mockAdapter],
92
- client: mockClient,
93
- });
94
-
95
- const errorBrain = brain('Error Brain').step('Error Step', () => {
96
- throw new Error('Test error');
97
- });
98
-
99
- try {
100
- await runner.run(errorBrain);
101
- } catch (error) {
102
- // Expected error
103
- }
104
-
105
- // Verify error event was dispatched
106
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
107
- expect.objectContaining({
108
- type: BRAIN_EVENTS.ERROR,
109
- error: expect.objectContaining({
110
- message: 'Test error',
111
- }),
112
- })
113
- );
114
- });
115
-
116
- it('should maintain state between steps', async () => {
117
- const runner = new BrainRunner({
118
- adapters: [],
119
- client: mockClient,
120
- });
121
-
122
- const testBrain = brain('Test Brain')
123
- .step('First Step', () => ({ count: 1 }))
124
- .step('Second Step', ({ state }) => ({
125
- count: state.count + 1,
126
- }));
127
-
128
- const result = await runner.run(testBrain);
129
-
130
- expect(result.count).toEqual(2);
131
- });
132
-
133
- it('should pass resources to step actions', async () => {
134
- const mockLoad = jest.fn(
135
- async (
136
- resourceName: string,
137
- type?: 'text' | 'binary'
138
- ): Promise<string | Buffer> => {
139
- if (type === 'binary') {
140
- return Buffer.from(`content of ${resourceName}`);
141
- }
142
- return `content of ${resourceName}`;
143
- }
144
- ) as jest.MockedFunction<ResourceLoader['load']>;
145
-
146
- const mockResourceLoader: ResourceLoader = {
147
- load: mockLoad,
148
- };
149
-
150
- const testManifest = {
151
- myTextFile: {
152
- type: 'text' as const,
153
- key: 'myTextFile',
154
- path: '/test/myTextFile.txt',
155
- },
156
- myBinaryFile: {
157
- type: 'binary' as const,
158
- key: 'myBinaryFile',
159
- path: '/test/myBinaryFile.bin',
160
- },
161
- } as const;
162
-
163
- const testResources = createResources(mockResourceLoader, testManifest);
164
-
165
- const runner = new BrainRunner({
166
- adapters: [],
167
- client: mockClient,
168
- }).withResources(testResources);
169
-
170
- let textContent: string | undefined;
171
- let binaryContent: Buffer | undefined;
172
-
173
- const resourceConsumingBrain = brain('Resource Brain').step(
174
- 'Load Resources',
175
- async ({ resources }) => {
176
- textContent = await (resources.myTextFile as any).loadText();
177
- binaryContent = await (resources.myBinaryFile as any).loadBinary();
178
- return {};
179
- }
180
- );
181
-
182
- await runner.run(resourceConsumingBrain);
183
-
184
- expect(mockLoad).toHaveBeenCalledWith('myTextFile', 'text');
185
- expect(mockLoad).toHaveBeenCalledWith('myBinaryFile', 'binary');
186
- expect(textContent).toBe('content of myTextFile');
187
- expect(binaryContent?.toString()).toBe('content of myBinaryFile');
188
- });
189
-
190
- it('should chain adapters with withAdapters method', async () => {
191
- const mockAdapter2: jest.Mocked<Adapter> = {
192
- dispatch: jest.fn(),
193
- };
194
- const mockAdapter3: jest.Mocked<Adapter> = {
195
- dispatch: jest.fn(),
196
- };
197
-
198
- const runner = new BrainRunner({
199
- adapters: [mockAdapter],
200
- client: mockClient,
201
- });
202
-
203
- // Chain additional adapters
204
- const updatedRunner = runner.withAdapters([mockAdapter2, mockAdapter3]);
205
-
206
- const testBrain = brain('Test Brain').step('Step 1', () => ({ value: 1 }));
207
-
208
- await updatedRunner.run(testBrain);
209
-
210
- // Verify all adapters received events
211
- expect(mockAdapter.dispatch).toHaveBeenCalledWith(
212
- expect.objectContaining({
213
- type: BRAIN_EVENTS.START,
214
- })
215
- );
216
- expect(mockAdapter2.dispatch).toHaveBeenCalledWith(
217
- expect.objectContaining({
218
- type: BRAIN_EVENTS.START,
219
- })
220
- );
221
- expect(mockAdapter3.dispatch).toHaveBeenCalledWith(
222
- expect.objectContaining({
223
- type: BRAIN_EVENTS.START,
224
- })
225
- );
226
-
227
- // Verify all adapters received the same number of events
228
- expect(mockAdapter.dispatch).toHaveBeenCalledTimes(
229
- mockAdapter2.dispatch.mock.calls.length
230
- );
231
- expect(mockAdapter2.dispatch).toHaveBeenCalledTimes(
232
- mockAdapter3.dispatch.mock.calls.length
233
- );
234
- });
235
-
236
- it('should replace client with withClient method', async () => {
237
- const originalClient: jest.Mocked<ObjectGenerator> = {
238
- generateObject: jest.fn(),
239
- };
240
- const newClient: jest.Mocked<ObjectGenerator> = {
241
- generateObject: jest.fn(),
242
- };
243
-
244
- // Configure the new client's response
245
- newClient.generateObject.mockResolvedValue({ result: 'from new client' });
246
-
247
- const runner = new BrainRunner({
248
- adapters: [],
249
- client: originalClient,
250
- });
251
-
252
- // Replace the client
253
- const updatedRunner = runner.withClient(newClient);
254
-
255
- // Define schema once to ensure same reference
256
- const testSchema = z.object({ result: z.string() });
257
-
258
- const testBrain = brain('Test Brain').step(
259
- 'Generate',
260
- async ({ client }) => {
261
- const response = await client.generateObject({
262
- prompt: 'test prompt',
263
- schema: testSchema,
264
- schemaName: 'TestSchema',
265
- });
266
- return { generated: response.result };
267
- }
268
- );
269
-
270
- const result = await updatedRunner.run(testBrain);
271
-
272
- // Verify new client was used, not the original
273
- expect(originalClient.generateObject).not.toHaveBeenCalled();
274
- expect(newClient.generateObject).toHaveBeenCalledWith({
275
- prompt: 'test prompt',
276
- schema: testSchema,
277
- schemaName: 'TestSchema',
278
- });
279
- expect(result.generated).toBe('from new client');
280
- });
281
-
282
- it('should apply patches from initialCompletedSteps and continue from correct state', async () => {
283
- const runner = new BrainRunner({
284
- adapters: [mockAdapter],
285
- client: mockClient,
286
- });
287
-
288
- // Simulate completed steps with patches
289
- const completedSteps: SerializedStep[] = [
290
- {
291
- id: 'step-1',
292
- title: 'First Step',
293
- status: STATUS.COMPLETE,
294
- patch: [
295
- {
296
- op: 'add',
297
- path: '/count',
298
- value: 10,
299
- },
300
- ],
301
- },
302
- {
303
- id: 'step-2',
304
- title: 'Second Step',
305
- status: STATUS.COMPLETE,
306
- patch: [
307
- {
308
- op: 'add',
309
- path: '/name',
310
- value: 'test',
311
- },
312
- ],
313
- },
314
- ];
315
-
316
- const testBrain = brain('Test Brain')
317
- .step('First Step', () => ({ count: 10 }))
318
- .step('Second Step', ({ state }) => ({ ...state, name: 'test' }))
319
- .step('Third Step', ({ state }) => ({
320
- ...state,
321
- count: state.count + 5,
322
- message: `${state.name} completed`,
323
- }));
324
-
325
- const result = await runner.run(testBrain, {
326
- initialCompletedSteps: completedSteps,
327
- brainRunId: 'test-run-123',
328
- });
329
-
330
- // Verify the final state includes patches from completed steps
331
- expect(result).toEqual({
332
- count: 15,
333
- name: 'test',
334
- message: 'test completed',
335
- });
336
-
337
- // Verify that the brain runner applied the patches correctly
338
- // The runner should have seen all steps execute, but the first two were already completed
339
- const stepCompleteEvents = mockAdapter.dispatch.mock.calls.filter(
340
- (call) => call[0].type === BRAIN_EVENTS.STEP_COMPLETE
341
- );
342
-
343
- // All steps will emit complete events in the current implementation
344
- expect(stepCompleteEvents.length).toBeGreaterThanOrEqual(1);
345
- });
346
-
347
- it('should stop execution after specified number of steps with endAfter parameter', async () => {
348
- const runner = new BrainRunner({
349
- adapters: [mockAdapter],
350
- client: mockClient,
351
- });
352
-
353
- const testBrain = brain('Test Brain')
354
- .step('Step 1', () => ({ step1: 'done' }))
355
- .step('Step 2', ({ state }) => ({ ...state, step2: 'done' }))
356
- .step('Step 3', ({ state }) => ({ ...state, step3: 'done' }))
357
- .step('Step 4', ({ state }) => ({ ...state, step4: 'done' }));
358
-
359
- // Run brain but stop after 2 steps
360
- const result = await runner.run(testBrain, {
361
- endAfter: 2,
362
- });
363
-
364
- // Verify state only has results from first 2 steps
365
- expect(result).toEqual({
366
- step1: 'done',
367
- step2: 'done',
368
- });
369
-
370
- // Verify only 2 step complete events were dispatched
371
- const stepCompleteEvents = mockAdapter.dispatch.mock.calls
372
- .filter((call) => call[0].type === BRAIN_EVENTS.STEP_COMPLETE)
373
- .map((call) => (call[0] as any).stepTitle);
374
-
375
- expect(stepCompleteEvents).toEqual(['Step 1', 'Step 2']);
376
-
377
- // Verify that COMPLETE event was NOT dispatched (brain didn't finish)
378
- const completeEvents = mockAdapter.dispatch.mock.calls.filter(
379
- (call) => call[0].type === BRAIN_EVENTS.COMPLETE
380
- );
381
-
382
- expect(completeEvents.length).toBe(0);
383
- });
384
- });
@@ -1,111 +0,0 @@
1
- import { BRAIN_EVENTS } from './constants.js';
2
- import { applyPatches } from './json-patch.js';
3
- import type { Adapter } from '../adapters/types.js';
4
- import type { SerializedStep, Brain } from './brain.js';
5
- import type { State } from './types.js';
6
- import type { ObjectGenerator } from '../clients/types.js';
7
- import type { Resources } from '../resources/resources.js';
8
-
9
- export class BrainRunner {
10
- constructor(
11
- private options: {
12
- adapters: Adapter[];
13
- client: ObjectGenerator;
14
- resources?: Resources;
15
- }
16
- ) {}
17
-
18
- withAdapters(adapters: Adapter[]): BrainRunner {
19
- const { adapters: existingAdapters } = this.options;
20
- return new BrainRunner({
21
- ...this.options,
22
- adapters: [...existingAdapters, ...adapters],
23
- });
24
- }
25
-
26
- withClient(client: ObjectGenerator): BrainRunner {
27
- return new BrainRunner({
28
- ...this.options,
29
- client,
30
- });
31
- }
32
-
33
- withResources(resources: Resources): BrainRunner {
34
- return new BrainRunner({
35
- ...this.options,
36
- resources,
37
- });
38
- }
39
-
40
- async run<TOptions extends object = {}, TState extends State = {}>(
41
- brain: Brain<TOptions, TState, any>,
42
- {
43
- initialState = {} as TState,
44
- options,
45
- initialCompletedSteps,
46
- brainRunId,
47
- endAfter,
48
- }: {
49
- initialState?: TState;
50
- options?: TOptions;
51
- initialCompletedSteps?: SerializedStep[] | never;
52
- brainRunId?: string | never;
53
- endAfter?: number;
54
- } = {}
55
- ): Promise<TState> {
56
- const { adapters, client, resources } = this.options;
57
-
58
- let currentState = initialState ?? ({} as TState);
59
- let stepNumber = 1;
60
-
61
- // Apply any patches from completed steps
62
- // to the initial state so that the brain
63
- // starts with a state that reflects all of the completed steps.
64
- // Need to do this when a brain is restarted with completed steps.
65
- initialCompletedSteps?.forEach((step) => {
66
- if (step.patch) {
67
- currentState = applyPatches(currentState, [step.patch]) as TState;
68
- stepNumber++;
69
- }
70
- });
71
-
72
- const brainRun =
73
- brainRunId && initialCompletedSteps
74
- ? brain.run({
75
- initialState,
76
- initialCompletedSteps,
77
- brainRunId,
78
- options,
79
- client,
80
- resources: resources ?? {},
81
- })
82
- : brain.run({
83
- initialState,
84
- options,
85
- client,
86
- brainRunId,
87
- resources: resources ?? {},
88
- });
89
-
90
- for await (const event of brainRun) {
91
- // Dispatch event to all adapters
92
- await Promise.all(adapters.map((adapter) => adapter.dispatch(event)));
93
-
94
- // Update current state when steps complete
95
- if (event.type === BRAIN_EVENTS.STEP_COMPLETE) {
96
- if (event.patch) {
97
- currentState = applyPatches(currentState, [event.patch]) as TState;
98
- }
99
-
100
- // Check if we should stop after this step
101
- if (endAfter && stepNumber >= endAfter) {
102
- return currentState;
103
- }
104
-
105
- stepNumber++;
106
- }
107
- }
108
-
109
- return currentState;
110
- }
111
- }