@positronic/core 0.0.1 → 0.0.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/CLAUDE.md +141 -0
- package/dist/src/adapters/types.js +1 -16
- package/dist/src/clients/types.js +4 -1
- package/dist/src/dsl/brain-runner.js +487 -0
- package/dist/src/dsl/brain-runner.test.js +733 -0
- package/dist/src/dsl/brain.js +1128 -0
- package/dist/src/dsl/brain.test.js +4225 -0
- package/dist/src/dsl/constants.js +6 -6
- package/dist/src/dsl/json-patch.js +37 -9
- package/dist/src/index.js +11 -10
- package/dist/src/resources/resources.js +371 -0
- package/dist/src/test-utils.js +474 -0
- package/dist/src/testing.js +3 -0
- package/dist/types/adapters/types.d.ts +3 -8
- package/dist/types/adapters/types.d.ts.map +1 -1
- package/dist/types/clients/types.d.ts +46 -6
- package/dist/types/clients/types.d.ts.map +1 -1
- package/dist/types/dsl/brain-runner.d.ts +24 -0
- package/dist/types/dsl/brain-runner.d.ts.map +1 -0
- package/dist/types/dsl/brain.d.ts +136 -0
- package/dist/types/dsl/brain.d.ts.map +1 -0
- package/dist/types/dsl/constants.d.ts +5 -5
- package/dist/types/dsl/constants.d.ts.map +1 -1
- package/dist/types/dsl/json-patch.d.ts +2 -1
- package/dist/types/dsl/json-patch.d.ts.map +1 -1
- package/dist/types/index.d.ts +13 -11
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/resources/resource-loader.d.ts +6 -0
- package/dist/types/resources/resource-loader.d.ts.map +1 -0
- package/dist/types/resources/resources.d.ts +23 -0
- package/dist/types/resources/resources.d.ts.map +1 -0
- package/dist/types/test-utils.d.ts +94 -0
- package/dist/types/test-utils.d.ts.map +1 -0
- package/dist/types/testing.d.ts +2 -0
- package/dist/types/testing.d.ts.map +1 -0
- package/docs/core-testing-guide.md +289 -0
- package/package.json +26 -7
- package/src/adapters/types.ts +3 -22
- package/src/clients/types.ts +50 -10
- package/src/dsl/brain-runner.test.ts +384 -0
- package/src/dsl/brain-runner.ts +111 -0
- package/src/dsl/brain.test.ts +1981 -0
- package/src/dsl/brain.ts +740 -0
- package/src/dsl/constants.ts +6 -6
- package/src/dsl/json-patch.ts +24 -9
- package/src/dsl/types.ts +1 -1
- package/src/index.ts +30 -16
- package/src/resources/resource-loader.ts +8 -0
- package/src/resources/resources.ts +267 -0
- package/src/test-utils.ts +254 -0
- package/test/resources.test.ts +248 -0
- package/tsconfig.json +2 -2
- package/.swcrc +0 -31
- package/dist/src/dsl/extensions.js +0 -19
- package/dist/src/dsl/workflow-runner.js +0 -93
- package/dist/src/dsl/workflow.js +0 -308
- package/dist/src/file-stores/local-file-store.js +0 -12
- package/dist/src/utils/temp-files.js +0 -27
- package/dist/types/dsl/extensions.d.ts +0 -18
- package/dist/types/dsl/extensions.d.ts.map +0 -1
- package/dist/types/dsl/workflow-runner.d.ts +0 -28
- package/dist/types/dsl/workflow-runner.d.ts.map +0 -1
- package/dist/types/dsl/workflow.d.ts +0 -118
- package/dist/types/dsl/workflow.d.ts.map +0 -1
- package/dist/types/file-stores/local-file-store.d.ts +0 -7
- package/dist/types/file-stores/local-file-store.d.ts.map +0 -1
- package/dist/types/file-stores/types.d.ts +0 -4
- package/dist/types/file-stores/types.d.ts.map +0 -1
- package/dist/types/utils/temp-files.d.ts +0 -12
- package/dist/types/utils/temp-files.d.ts.map +0 -1
- package/src/dsl/extensions.ts +0 -58
- package/src/dsl/workflow-runner.test.ts +0 -203
- package/src/dsl/workflow-runner.ts +0 -146
- package/src/dsl/workflow.test.ts +0 -1435
- package/src/dsl/workflow.ts +0 -554
- package/src/file-stores/local-file-store.ts +0 -11
- package/src/file-stores/types.ts +0 -3
- package/src/utils/temp-files.ts +0 -46
- /package/dist/src/{file-stores/types.js → resources/resource-loader.js} +0 -0
package/package.json
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@positronic/core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Core services and tools for positronic AI brains",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"main": "dist/src/index.js",
|
|
7
10
|
"types": "dist/types/index.d.ts",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/types/index.d.ts",
|
|
15
|
+
"import": "./dist/src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./dist/types/testing.d.ts",
|
|
19
|
+
"import": "./dist/src/testing.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
8
22
|
"scripts": {
|
|
9
23
|
"tsc": "tsc --project tsconfig.json",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
24
|
+
"swc": "swc src -d dist",
|
|
25
|
+
"build": "npm run tsc && npm run swc",
|
|
26
|
+
"clean": "rm -rf tsconfig.tsbuildinfo dist node_modules",
|
|
27
|
+
"test": "jest --silent"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"zod": "^3.24.1"
|
|
12
31
|
},
|
|
13
32
|
"dependencies": {
|
|
14
33
|
"fast-json-patch": "^3.1.1",
|
|
15
|
-
"uuid": "^11.0.5"
|
|
16
|
-
"zod": "^3.24.1"
|
|
34
|
+
"uuid": "^11.0.5"
|
|
17
35
|
},
|
|
18
36
|
"devDependencies": {
|
|
19
|
-
"@types/uuid": "^10.0.0"
|
|
37
|
+
"@types/uuid": "^10.0.0",
|
|
38
|
+
"zod": "^3.24.1"
|
|
20
39
|
}
|
|
21
40
|
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -1,24 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { WorkflowEvent } from '../dsl/workflow';
|
|
1
|
+
import type { BrainEvent } from '../dsl/brain.js';
|
|
3
2
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
async updated?(event: WorkflowEvent<Options>): Promise<void>;
|
|
7
|
-
async completed?(event: WorkflowEvent<Options>): Promise<void>;
|
|
8
|
-
async error?(event: WorkflowEvent<Options>): Promise<void>;
|
|
9
|
-
async restarted?(event: WorkflowEvent<Options>): Promise<void>;
|
|
10
|
-
|
|
11
|
-
async dispatch(event: WorkflowEvent<Options>) {
|
|
12
|
-
if (event.type === WORKFLOW_EVENTS.START && this.started) {
|
|
13
|
-
await this.started(event);
|
|
14
|
-
} else if (event.type === WORKFLOW_EVENTS.STEP_COMPLETE && this.updated) {
|
|
15
|
-
await this.updated(event);
|
|
16
|
-
} else if (event.type === WORKFLOW_EVENTS.COMPLETE && this.completed) {
|
|
17
|
-
await this.completed(event);
|
|
18
|
-
} else if (event.type === WORKFLOW_EVENTS.ERROR && this.error) {
|
|
19
|
-
await this.error(event);
|
|
20
|
-
} else if (event.type === WORKFLOW_EVENTS.RESTART && this.restarted) {
|
|
21
|
-
await this.restarted(event);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
3
|
+
export interface Adapter<Options extends object = any> {
|
|
4
|
+
dispatch(event: BrainEvent<Options>): void | Promise<void>;
|
|
24
5
|
}
|
package/src/clients/types.ts
CHANGED
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Represents a message in a conversation, used as input for the Generator.
|
|
5
|
+
*/
|
|
6
|
+
export type Message = {
|
|
7
|
+
role: 'user' | 'assistant' | 'system';
|
|
8
|
+
content: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Interface for AI model interactions, focused on generating structured objects
|
|
13
|
+
* and potentially other types of content in the future.
|
|
14
|
+
*/
|
|
15
|
+
export interface ObjectGenerator {
|
|
16
|
+
/**
|
|
17
|
+
* Generates a structured JSON object that conforms to the provided Zod schema.
|
|
18
|
+
*
|
|
19
|
+
* This method supports both simple single-string prompts and more complex
|
|
20
|
+
* multi-turn conversations via the `messages` array.
|
|
21
|
+
*/
|
|
22
|
+
generateObject<T extends z.AnyZodObject>(params: {
|
|
23
|
+
/**
|
|
24
|
+
* The definition of the expected output object, including its Zod schema
|
|
25
|
+
* and a name for state management within the brain.
|
|
26
|
+
*/
|
|
4
27
|
schema: T;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
28
|
+
schemaName: string;
|
|
29
|
+
schemaDescription?: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A simple prompt string for single-turn requests.
|
|
33
|
+
* If provided, this will typically be treated as the latest user input.
|
|
34
|
+
* If `messages` are also provided, this `prompt` is usually appended
|
|
35
|
+
* as a new user message to the existing `messages` array.
|
|
36
|
+
*/
|
|
37
|
+
prompt?: string;
|
|
8
38
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
39
|
+
/**
|
|
40
|
+
* An array of messages forming the conversation history.
|
|
41
|
+
* Use this for multi-turn conversations or when you need to provide
|
|
42
|
+
* a sequence of interactions (e.g., user, assistant, tool calls).
|
|
43
|
+
* If `prompt` is also provided, it's typically added to this history.
|
|
44
|
+
*/
|
|
45
|
+
messages?: Message[];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* An optional system-level instruction or context to guide the model's
|
|
49
|
+
* behavior for the entire interaction. Implementations will typically
|
|
50
|
+
* prepend this as a `system` role message to the full message list.
|
|
51
|
+
*/
|
|
52
|
+
system?: string;
|
|
53
|
+
}): Promise<z.infer<T>>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
}
|