@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,738 @@
1
+ /**
2
+ * Agent framework integration tests
3
+ */
4
+ import { describe, it, beforeEach } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import { tmpdir } from 'node:os';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { AgentFramework, ProcessQueueImpl } from '../src/index.js';
10
+ /**
11
+ * Mock yielding stream for testing the streaming inference path.
12
+ * Takes a sequence of responses: first response starts the stream,
13
+ * subsequent responses resume after each tool round.
14
+ */
15
+ class MockYieldingStream {
16
+ responses;
17
+ events = [];
18
+ _done = false;
19
+ _isWaitingForTools = false;
20
+ _pendingToolCallIds = [];
21
+ _toolDepth = 0;
22
+ pendingResolve = null;
23
+ receivedToolResults = [];
24
+ constructor(responses) {
25
+ this.responses = responses;
26
+ this.processResponse(0);
27
+ }
28
+ processResponse(index) {
29
+ const response = this.responses[index];
30
+ if (!response) {
31
+ this._done = true;
32
+ return;
33
+ }
34
+ // Emit tokens
35
+ const text = response.rawAssistantText;
36
+ if (text) {
37
+ this.events.push({
38
+ type: 'tokens',
39
+ content: text,
40
+ meta: { type: 'text', visible: true, blockIndex: 0 },
41
+ });
42
+ }
43
+ // Emit usage
44
+ if (response.usage) {
45
+ this.events.push({ type: 'usage', usage: response.usage });
46
+ }
47
+ // Tool calls or complete
48
+ if (response.toolCalls.length > 0) {
49
+ this._isWaitingForTools = true;
50
+ this._pendingToolCallIds = response.toolCalls.map((c) => c.id);
51
+ this.events.push({
52
+ type: 'tool-calls',
53
+ calls: response.toolCalls.map((tc) => ({
54
+ id: tc.id,
55
+ name: tc.name,
56
+ input: tc.input,
57
+ })),
58
+ context: {
59
+ rawText: '',
60
+ preamble: '',
61
+ depth: this._toolDepth,
62
+ previousResults: [],
63
+ accumulated: '',
64
+ },
65
+ });
66
+ }
67
+ else {
68
+ this.events.push({ type: 'complete', response });
69
+ this._done = true;
70
+ }
71
+ }
72
+ provideToolResults(results) {
73
+ if (!this._isWaitingForTools)
74
+ throw new Error('Not waiting for tools');
75
+ this.receivedToolResults.push(results);
76
+ this._isWaitingForTools = false;
77
+ this._pendingToolCallIds = [];
78
+ this._toolDepth++;
79
+ this.processResponse(this._toolDepth);
80
+ // Wake up the iterator
81
+ if (this.pendingResolve) {
82
+ const resolve = this.pendingResolve;
83
+ this.pendingResolve = null;
84
+ resolve();
85
+ }
86
+ }
87
+ cancel() {
88
+ this._done = true;
89
+ this.events.push({ type: 'aborted', reason: 'user' });
90
+ if (this.pendingResolve) {
91
+ const resolve = this.pendingResolve;
92
+ this.pendingResolve = null;
93
+ resolve();
94
+ }
95
+ }
96
+ get isWaitingForTools() { return this._isWaitingForTools; }
97
+ get pendingToolCallIds() { return [...this._pendingToolCallIds]; }
98
+ get toolDepth() { return this._toolDepth; }
99
+ async *[Symbol.asyncIterator]() {
100
+ while (true) {
101
+ while (this.events.length > 0) {
102
+ const event = this.events.shift();
103
+ yield event;
104
+ if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') {
105
+ return;
106
+ }
107
+ }
108
+ if (this._done)
109
+ return;
110
+ await new Promise((resolve) => { this.pendingResolve = resolve; });
111
+ }
112
+ }
113
+ }
114
+ function createMockResponse(content, stopReason = 'end_turn') {
115
+ const rawText = content
116
+ .filter((b) => b.type === 'text')
117
+ .map((b) => b.text)
118
+ .join('');
119
+ const toolCalls = content
120
+ .filter((b) => b.type === 'tool_use')
121
+ .map((b) => ({ id: b.id, name: b.name, input: b.input }));
122
+ return {
123
+ content,
124
+ stopReason,
125
+ rawAssistantText: rawText,
126
+ toolCalls,
127
+ toolResults: [],
128
+ usage: { inputTokens: 10, outputTokens: 5 },
129
+ details: {
130
+ stop: { reason: stopReason, wasTruncated: false },
131
+ usage: { inputTokens: 10, outputTokens: 5 },
132
+ timing: { totalDurationMs: 100, attempts: 1 },
133
+ model: { requested: 'test', actual: 'test', provider: 'mock' },
134
+ cache: { markersInRequest: 0, tokensCreated: 0, tokensRead: 0, hitRatio: 0 },
135
+ },
136
+ raw: { request: {}, response: {} },
137
+ };
138
+ }
139
+ class MockMembrane {
140
+ responses = [];
141
+ calls = [];
142
+ lastStream = null;
143
+ responseIndex = 0;
144
+ pushResponse(response) {
145
+ this.responses.push(response);
146
+ }
147
+ async complete(request) {
148
+ this.calls.push(request);
149
+ if (this.responseIndex >= this.responses.length) {
150
+ // Default response
151
+ return createMockResponse([{ type: 'text', text: 'Default response' }]);
152
+ }
153
+ return this.responses[this.responseIndex++];
154
+ }
155
+ streamYielding(request, _options) {
156
+ this.calls.push(request);
157
+ // All remaining queued responses feed the stream
158
+ const remaining = this.responses.slice(this.responseIndex);
159
+ this.responseIndex = this.responses.length;
160
+ const stream = new MockYieldingStream(remaining);
161
+ this.lastStream = stream;
162
+ return stream;
163
+ }
164
+ // Cast helper - use this when passing to framework
165
+ asMembrane() {
166
+ return this;
167
+ }
168
+ }
169
+ // ============================================================================
170
+ // Mock Module
171
+ // ============================================================================
172
+ class TestModule {
173
+ name = 'test';
174
+ ctx = null;
175
+ events = [];
176
+ toolCalls = [];
177
+ async start(ctx) {
178
+ this.ctx = ctx;
179
+ }
180
+ async stop() {
181
+ this.ctx = null;
182
+ }
183
+ getTools() {
184
+ return [
185
+ {
186
+ name: 'echo',
187
+ description: 'Echoes the input',
188
+ inputSchema: {
189
+ type: 'object',
190
+ properties: {
191
+ message: { type: 'string', description: 'Message to echo' },
192
+ },
193
+ required: ['message'],
194
+ },
195
+ },
196
+ ];
197
+ }
198
+ async handleToolCall(call) {
199
+ this.toolCalls.push(call);
200
+ if (call.name === 'echo') {
201
+ const input = call.input;
202
+ return { success: true, data: { echoed: input.message } };
203
+ }
204
+ return { success: false, error: 'Unknown tool', isError: true };
205
+ }
206
+ async onProcess(event, _state) {
207
+ this.events.push(event);
208
+ // Handle external message by requesting inference
209
+ if (event.type === 'external-message') {
210
+ return {
211
+ addMessages: [
212
+ {
213
+ participant: 'User',
214
+ content: [{ type: 'text', text: String(event.content) }],
215
+ },
216
+ ],
217
+ requestInference: true,
218
+ };
219
+ }
220
+ return {};
221
+ }
222
+ }
223
+ // ============================================================================
224
+ // Tests
225
+ // ============================================================================
226
+ describe('ProcessQueueImpl', () => {
227
+ it('should push and pop events', () => {
228
+ const queue = new ProcessQueueImpl();
229
+ const event = {
230
+ type: 'timer-fired',
231
+ timerId: 'test',
232
+ reason: 'test',
233
+ };
234
+ queue.push(event);
235
+ assert.strictEqual(queue.depth, 1);
236
+ assert.strictEqual(queue.isEmpty, false);
237
+ const popped = queue.tryPop();
238
+ assert.deepStrictEqual(popped, event);
239
+ assert.strictEqual(queue.depth, 0);
240
+ assert.strictEqual(queue.isEmpty, true);
241
+ });
242
+ it('should return null when queue is empty', () => {
243
+ const queue = new ProcessQueueImpl();
244
+ assert.strictEqual(queue.tryPop(), null);
245
+ });
246
+ it('should peek without removing', () => {
247
+ const queue = new ProcessQueueImpl();
248
+ const event = {
249
+ type: 'timer-fired',
250
+ timerId: 'test',
251
+ reason: 'test',
252
+ };
253
+ queue.push(event);
254
+ assert.deepStrictEqual(queue.peek(), event);
255
+ assert.strictEqual(queue.depth, 1);
256
+ });
257
+ it('should clear all events', () => {
258
+ const queue = new ProcessQueueImpl();
259
+ queue.push({ type: 'timer-fired', timerId: '1', reason: 'test' });
260
+ queue.push({ type: 'timer-fired', timerId: '2', reason: 'test' });
261
+ queue.clear();
262
+ assert.strictEqual(queue.isEmpty, true);
263
+ });
264
+ it('should close and prevent further pushes', () => {
265
+ const queue = new ProcessQueueImpl();
266
+ queue.close();
267
+ assert.throws(() => {
268
+ queue.push({ type: 'timer-fired', timerId: 'test', reason: 'test' });
269
+ }, /Queue is closed/);
270
+ });
271
+ });
272
+ describe('AgentFramework', () => {
273
+ let tempDir;
274
+ let membrane;
275
+ beforeEach(() => {
276
+ tempDir = mkdtempSync(join(tmpdir(), 'agent-framework-test-'));
277
+ membrane = new MockMembrane();
278
+ });
279
+ it('should create framework with agents and modules', async () => {
280
+ const testModule = new TestModule();
281
+ const framework = await AgentFramework.create({
282
+ storePath: join(tempDir, 'test.chronicle'),
283
+ membrane: membrane.asMembrane(),
284
+ agents: [
285
+ {
286
+ name: 'assistant',
287
+ model: 'test-model',
288
+ systemPrompt: 'You are a helpful assistant.',
289
+ },
290
+ ],
291
+ modules: [testModule],
292
+ });
293
+ assert.notStrictEqual(framework.getAgent('assistant'), null);
294
+ assert.deepStrictEqual(framework.getAllAgents().map((a) => a.name), ['assistant']);
295
+ assert.notStrictEqual(testModule.ctx, null);
296
+ await framework.stop();
297
+ rmSync(tempDir, { recursive: true });
298
+ });
299
+ it('should route external messages and trigger inference', async () => {
300
+ const testModule = new TestModule();
301
+ // Set up response
302
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello! How can I help?' }]));
303
+ const framework = await AgentFramework.create({
304
+ storePath: join(tempDir, 'test.chronicle'),
305
+ membrane: membrane.asMembrane(),
306
+ agents: [
307
+ {
308
+ name: 'assistant',
309
+ model: 'test-model',
310
+ systemPrompt: 'You are a helpful assistant.',
311
+ },
312
+ ],
313
+ modules: [testModule],
314
+ });
315
+ // Push an external message event
316
+ framework.pushEvent({
317
+ type: 'external-message',
318
+ source: 'test',
319
+ content: 'Hello!',
320
+ metadata: {},
321
+ });
322
+ // Run until idle
323
+ await framework.runUntilIdle();
324
+ // Check that module received the event
325
+ assert.strictEqual(testModule.events.length, 1);
326
+ assert.strictEqual(testModule.events[0].type, 'external-message');
327
+ // Check that membrane was called
328
+ assert.strictEqual(membrane.calls.length, 1);
329
+ assert.strictEqual(membrane.calls[0].system, 'You are a helpful assistant.');
330
+ await framework.stop();
331
+ rmSync(tempDir, { recursive: true });
332
+ });
333
+ it('should handle tool calls', async () => {
334
+ const testModule = new TestModule();
335
+ // First response with tool call
336
+ membrane.pushResponse(createMockResponse([
337
+ { type: 'text', text: 'Let me echo that for you.' },
338
+ {
339
+ type: 'tool_use',
340
+ id: 'call_1',
341
+ name: 'test--echo',
342
+ input: { message: 'test message' },
343
+ },
344
+ ], 'tool_use'));
345
+ // Second response after tool result
346
+ membrane.pushResponse(createMockResponse([
347
+ { type: 'text', text: 'The echoed message is: test message' },
348
+ ]));
349
+ const framework = await AgentFramework.create({
350
+ storePath: join(tempDir, 'test.chronicle'),
351
+ membrane: membrane.asMembrane(),
352
+ agents: [
353
+ {
354
+ name: 'assistant',
355
+ model: 'test-model',
356
+ systemPrompt: 'You are a helpful assistant.',
357
+ },
358
+ ],
359
+ modules: [testModule],
360
+ });
361
+ // Push message and run
362
+ framework.pushEvent({
363
+ type: 'external-message',
364
+ source: 'test',
365
+ content: 'Echo this!',
366
+ metadata: {},
367
+ });
368
+ await framework.runUntilIdle();
369
+ // Check tool was called
370
+ assert.strictEqual(testModule.toolCalls.length, 1);
371
+ assert.strictEqual(testModule.toolCalls[0].name, 'echo');
372
+ assert.deepStrictEqual(testModule.toolCalls[0].input, { message: 'test message' });
373
+ // With streaming, membrane is called once (streamYielding handles tool rounds internally)
374
+ assert.strictEqual(membrane.calls.length, 1);
375
+ await framework.stop();
376
+ rmSync(tempDir, { recursive: true });
377
+ });
378
+ it('should emit framework events', async () => {
379
+ const testModule = new TestModule();
380
+ const events = [];
381
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello!' }]));
382
+ const framework = await AgentFramework.create({
383
+ storePath: join(tempDir, 'test.chronicle'),
384
+ membrane: membrane.asMembrane(),
385
+ agents: [
386
+ {
387
+ name: 'assistant',
388
+ model: 'test-model',
389
+ systemPrompt: 'Test',
390
+ },
391
+ ],
392
+ modules: [testModule],
393
+ });
394
+ framework.onTrace((event) => {
395
+ events.push(event.type);
396
+ });
397
+ framework.pushEvent({
398
+ type: 'external-message',
399
+ source: 'test',
400
+ content: 'Hello',
401
+ metadata: {},
402
+ });
403
+ await framework.runUntilIdle();
404
+ assert.ok(events.includes('process:received'), 'Should emit process:received');
405
+ assert.ok(events.includes('inference:started'), 'Should emit inference:started');
406
+ assert.ok(events.includes('inference:completed'), 'Should emit inference:completed');
407
+ assert.ok(events.includes('message:added'), 'Should emit message:added');
408
+ await framework.stop();
409
+ rmSync(tempDir, { recursive: true });
410
+ });
411
+ it('should filter tools by agent allowedTools', async () => {
412
+ const testModule = new TestModule();
413
+ membrane.pushResponse(createMockResponse([
414
+ { type: 'text', text: 'No tools available' },
415
+ ]));
416
+ const framework = await AgentFramework.create({
417
+ storePath: join(tempDir, 'test.chronicle'),
418
+ membrane: membrane.asMembrane(),
419
+ agents: [
420
+ {
421
+ name: 'assistant',
422
+ model: 'test-model',
423
+ systemPrompt: 'Test',
424
+ allowedTools: ['nonexistent--tool'], // Won't match test--echo
425
+ },
426
+ ],
427
+ modules: [testModule],
428
+ });
429
+ framework.pushEvent({
430
+ type: 'external-message',
431
+ source: 'test',
432
+ content: 'Hello',
433
+ metadata: {},
434
+ });
435
+ await framework.runUntilIdle();
436
+ // Should have no tools in the request
437
+ assert.strictEqual(membrane.calls[0].tools, undefined);
438
+ await framework.stop();
439
+ rmSync(tempDir, { recursive: true });
440
+ });
441
+ it('should support adding and removing modules at runtime', async () => {
442
+ const framework = await AgentFramework.create({
443
+ storePath: join(tempDir, 'test.chronicle'),
444
+ membrane: membrane.asMembrane(),
445
+ agents: [
446
+ {
447
+ name: 'assistant',
448
+ model: 'test-model',
449
+ systemPrompt: 'Test',
450
+ },
451
+ ],
452
+ modules: [],
453
+ });
454
+ const testModule = new TestModule();
455
+ await framework.addModule(testModule);
456
+ assert.notStrictEqual(testModule.ctx, null);
457
+ await framework.removeModule('test');
458
+ assert.strictEqual(testModule.ctx, null);
459
+ await framework.stop();
460
+ rmSync(tempDir, { recursive: true });
461
+ });
462
+ it('should share store when provided', async () => {
463
+ // This test verifies that app-owned stores work
464
+ const { JsStore } = await import('@animalabs/chronicle');
465
+ const store = JsStore.openOrCreate({ path: join(tempDir, 'shared.chronicle') });
466
+ const framework = await AgentFramework.create({
467
+ store,
468
+ membrane: membrane.asMembrane(),
469
+ agents: [
470
+ {
471
+ name: 'assistant',
472
+ model: 'test-model',
473
+ systemPrompt: 'Test',
474
+ },
475
+ ],
476
+ modules: [],
477
+ });
478
+ // Framework should work
479
+ assert.notStrictEqual(framework.getStore(), null);
480
+ assert.strictEqual(framework.getStore(), store);
481
+ await framework.stop();
482
+ // Store should still be open (framework doesn't own it)
483
+ // We can close it ourselves
484
+ store.close();
485
+ rmSync(tempDir, { recursive: true });
486
+ });
487
+ });
488
+ describe('Streaming lifecycle', () => {
489
+ let tempDir;
490
+ let membrane;
491
+ beforeEach(() => {
492
+ tempDir = mkdtempSync(join(tmpdir(), 'agent-framework-stream-'));
493
+ membrane = new MockMembrane();
494
+ });
495
+ it('should complete a simple stream without tool calls', async () => {
496
+ const testModule = new TestModule();
497
+ const events = [];
498
+ membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello from stream' }]));
499
+ const framework = await AgentFramework.create({
500
+ storePath: join(tempDir, 'test.chronicle'),
501
+ membrane: membrane.asMembrane(),
502
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Test' }],
503
+ modules: [testModule],
504
+ });
505
+ framework.onTrace((event) => events.push(event.type));
506
+ framework.pushEvent({
507
+ type: 'external-message',
508
+ source: 'test',
509
+ content: 'Hello',
510
+ metadata: {},
511
+ });
512
+ await framework.runUntilIdle();
513
+ assert.ok(events.includes('inference:started'));
514
+ assert.ok(events.includes('inference:tokens'));
515
+ assert.ok(events.includes('inference:completed'));
516
+ // Agent should be idle after completion
517
+ const agent = framework.getAgent('assistant');
518
+ assert.strictEqual(agent.state.status, 'idle');
519
+ await framework.stop();
520
+ rmSync(tempDir, { recursive: true });
521
+ });
522
+ it('should handle multi-round tool calls in a single stream', async () => {
523
+ const testModule = new TestModule();
524
+ // Round 1: tool call
525
+ membrane.pushResponse(createMockResponse([
526
+ { type: 'text', text: 'First echo.' },
527
+ { type: 'tool_use', id: 'call_1', name: 'test--echo', input: { message: 'round1' } },
528
+ ], 'tool_use'));
529
+ // Round 2: another tool call
530
+ membrane.pushResponse(createMockResponse([
531
+ { type: 'text', text: 'Second echo.' },
532
+ { type: 'tool_use', id: 'call_2', name: 'test--echo', input: { message: 'round2' } },
533
+ ], 'tool_use'));
534
+ // Round 3: final response
535
+ membrane.pushResponse(createMockResponse([
536
+ { type: 'text', text: 'Done with both echoes.' },
537
+ ]));
538
+ const framework = await AgentFramework.create({
539
+ storePath: join(tempDir, 'test.chronicle'),
540
+ membrane: membrane.asMembrane(),
541
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Test' }],
542
+ modules: [testModule],
543
+ });
544
+ framework.pushEvent({
545
+ type: 'external-message',
546
+ source: 'test',
547
+ content: 'Do two echoes',
548
+ metadata: {},
549
+ });
550
+ await framework.runUntilIdle();
551
+ // Both tools were called
552
+ assert.strictEqual(testModule.toolCalls.length, 2);
553
+ assert.deepStrictEqual(testModule.toolCalls[0].input, { message: 'round1' });
554
+ assert.deepStrictEqual(testModule.toolCalls[1].input, { message: 'round2' });
555
+ // Still only one membrane call (one stream)
556
+ assert.strictEqual(membrane.calls.length, 1);
557
+ // Agent is idle
558
+ assert.strictEqual(framework.getAgent('assistant').state.status, 'idle');
559
+ await framework.stop();
560
+ rmSync(tempDir, { recursive: true });
561
+ });
562
+ it('should cancel active streams on stop', async () => {
563
+ const testModule = new TestModule();
564
+ // Response with a tool call but no follow-up (stream will wait forever)
565
+ membrane.pushResponse(createMockResponse([
566
+ { type: 'tool_use', id: 'call_hang', name: 'test--echo', input: { message: 'hang' } },
567
+ ], 'tool_use'));
568
+ const framework = await AgentFramework.create({
569
+ storePath: join(tempDir, 'test.chronicle'),
570
+ membrane: membrane.asMembrane(),
571
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Test' }],
572
+ modules: [testModule],
573
+ });
574
+ framework.pushEvent({
575
+ type: 'external-message',
576
+ source: 'test',
577
+ content: 'Hang',
578
+ metadata: {},
579
+ });
580
+ // Process the event and start streaming (but don't wait for idle — it won't happen)
581
+ // Let the stream start and dispatch the tool call
582
+ for (let i = 0; i < 10; i++) {
583
+ await new Promise((resolve) => setTimeout(resolve, 5));
584
+ }
585
+ // stop() should cancel active streams and not hang
586
+ await framework.stop();
587
+ // Agent should be idle after cancellation
588
+ assert.strictEqual(framework.getAgent('assistant').state.status, 'idle');
589
+ rmSync(tempDir, { recursive: true });
590
+ });
591
+ it('should emit tool_calls_yielded and stream_resumed traces', async () => {
592
+ const testModule = new TestModule();
593
+ const events = [];
594
+ membrane.pushResponse(createMockResponse([
595
+ { type: 'tool_use', id: 'call_t', name: 'test--echo', input: { message: 'trace' } },
596
+ ], 'tool_use'));
597
+ membrane.pushResponse(createMockResponse([
598
+ { type: 'text', text: 'Done' },
599
+ ]));
600
+ const framework = await AgentFramework.create({
601
+ storePath: join(tempDir, 'test.chronicle'),
602
+ membrane: membrane.asMembrane(),
603
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Test' }],
604
+ modules: [testModule],
605
+ });
606
+ framework.onTrace((event) => events.push(event.type));
607
+ framework.pushEvent({
608
+ type: 'external-message',
609
+ source: 'test',
610
+ content: 'Trace test',
611
+ metadata: {},
612
+ });
613
+ await framework.runUntilIdle();
614
+ assert.ok(events.includes('inference:tool_calls_yielded'), 'Should emit tool_calls_yielded');
615
+ assert.ok(events.includes('inference:stream_resumed'), 'Should emit stream_resumed');
616
+ assert.ok(events.includes('inference:completed'), 'Should emit completed');
617
+ await framework.stop();
618
+ rmSync(tempDir, { recursive: true });
619
+ });
620
+ it('should convert AF tool results to Membrane format when resuming stream', async () => {
621
+ const testModule = new TestModule();
622
+ membrane.pushResponse(createMockResponse([
623
+ { type: 'tool_use', id: 'call_conv', name: 'test--echo', input: { message: 'convert' } },
624
+ ], 'tool_use'));
625
+ membrane.pushResponse(createMockResponse([
626
+ { type: 'text', text: 'Converted' },
627
+ ]));
628
+ const framework = await AgentFramework.create({
629
+ storePath: join(tempDir, 'test.chronicle'),
630
+ membrane: membrane.asMembrane(),
631
+ agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Test' }],
632
+ modules: [testModule],
633
+ });
634
+ framework.pushEvent({
635
+ type: 'external-message',
636
+ source: 'test',
637
+ content: 'Convert test',
638
+ metadata: {},
639
+ });
640
+ await framework.runUntilIdle();
641
+ // Verify the mock stream received correctly converted tool results
642
+ const stream = membrane.lastStream;
643
+ assert.strictEqual(stream.receivedToolResults.length, 1);
644
+ const results = stream.receivedToolResults[0];
645
+ assert.strictEqual(results.length, 1);
646
+ assert.strictEqual(results[0].toolUseId, 'call_conv');
647
+ assert.strictEqual(results[0].content, JSON.stringify({ echoed: 'convert' }));
648
+ assert.strictEqual(results[0].isError, undefined);
649
+ await framework.stop();
650
+ rmSync(tempDir, { recursive: true });
651
+ });
652
+ });
653
+ describe('Module state persistence', () => {
654
+ let tempDir;
655
+ let membrane;
656
+ beforeEach(() => {
657
+ tempDir = mkdtempSync(join(tmpdir(), 'agent-framework-test-'));
658
+ membrane = new MockMembrane();
659
+ });
660
+ it('should persist and restore module state', async () => {
661
+ class StatefulModule {
662
+ name = 'stateful';
663
+ ctx = null;
664
+ async start(ctx) {
665
+ this.ctx = ctx;
666
+ // Set initial state
667
+ const existing = ctx.getState();
668
+ if (!existing) {
669
+ ctx.setState({ count: 1 });
670
+ }
671
+ else {
672
+ ctx.setState({ count: existing.count + 1 });
673
+ }
674
+ }
675
+ async stop() { }
676
+ getTools() { return []; }
677
+ async handleToolCall() {
678
+ return { success: false, error: 'No tools', isError: true };
679
+ }
680
+ async onProcess() { return {}; }
681
+ }
682
+ const storePath = join(tempDir, 'state.chronicle');
683
+ // First run
684
+ const module1 = new StatefulModule();
685
+ const framework1 = await AgentFramework.create({
686
+ storePath,
687
+ membrane: membrane.asMembrane(),
688
+ agents: [{ name: 'a', model: 'm', systemPrompt: 's' }],
689
+ modules: [module1],
690
+ });
691
+ assert.deepStrictEqual(module1.ctx?.getState()?.count, 1);
692
+ await framework1.stop();
693
+ // Second run - state should be restored
694
+ const module2 = new StatefulModule();
695
+ const framework2 = await AgentFramework.create({
696
+ storePath,
697
+ membrane: membrane.asMembrane(),
698
+ agents: [{ name: 'a', model: 'm', systemPrompt: 's' }],
699
+ modules: [module2],
700
+ });
701
+ assert.deepStrictEqual(module2.ctx?.getState()?.count, 2);
702
+ await framework2.stop();
703
+ rmSync(tempDir, { recursive: true });
704
+ });
705
+ it('should track external IDs', async () => {
706
+ class IdTrackingModule {
707
+ name = 'idtracker';
708
+ ctx = null;
709
+ async start(ctx) {
710
+ this.ctx = ctx;
711
+ }
712
+ async stop() { }
713
+ getTools() { return []; }
714
+ async handleToolCall() {
715
+ return { success: false, error: 'No tools', isError: true };
716
+ }
717
+ async onProcess() { return {}; }
718
+ }
719
+ const module = new IdTrackingModule();
720
+ const framework = await AgentFramework.create({
721
+ storePath: join(tempDir, 'ids.chronicle'),
722
+ membrane: membrane.asMembrane(),
723
+ agents: [{ name: 'a', model: 'm', systemPrompt: 's' }],
724
+ modules: [module],
725
+ });
726
+ // Add a message with external ID
727
+ const msgId = module.ctx.addMessage('User', [{ type: 'text', text: 'Hello from Discord' }], { external: { source: 'discord', id: 'msg123' } });
728
+ // Should be able to look it up
729
+ const found = module.ctx.findMessageByExternalId('discord', 'msg123');
730
+ assert.strictEqual(found, msgId);
731
+ // Unknown ID should return null
732
+ const notFound = module.ctx.findMessageByExternalId('discord', 'unknown');
733
+ assert.strictEqual(notFound, null);
734
+ await framework.stop();
735
+ rmSync(tempDir, { recursive: true });
736
+ });
737
+ });
738
+ //# sourceMappingURL=framework.test.js.map