@flink-app/flink 1.0.0 → 2.0.0-alpha.48

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 (109) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cli/build.ts +8 -1
  3. package/cli/run.ts +8 -1
  4. package/dist/cli/build.js +8 -1
  5. package/dist/cli/run.js +8 -1
  6. package/dist/src/FlinkApp.d.ts +33 -0
  7. package/dist/src/FlinkApp.js +247 -27
  8. package/dist/src/FlinkContext.d.ts +21 -0
  9. package/dist/src/FlinkHttpHandler.d.ts +90 -1
  10. package/dist/src/TypeScriptCompiler.d.ts +42 -0
  11. package/dist/src/TypeScriptCompiler.js +346 -4
  12. package/dist/src/TypeScriptUtils.js +4 -0
  13. package/dist/src/ai/AgentRunner.d.ts +39 -0
  14. package/dist/src/ai/AgentRunner.js +625 -0
  15. package/dist/src/ai/FlinkAgent.d.ts +446 -0
  16. package/dist/src/ai/FlinkAgent.js +633 -0
  17. package/dist/src/ai/FlinkTool.d.ts +37 -0
  18. package/dist/src/ai/FlinkTool.js +2 -0
  19. package/dist/src/ai/LLMAdapter.d.ts +119 -0
  20. package/dist/src/ai/LLMAdapter.js +2 -0
  21. package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
  22. package/dist/src/ai/SubAgentExecutor.js +220 -0
  23. package/dist/src/ai/ToolExecutor.d.ts +35 -0
  24. package/dist/src/ai/ToolExecutor.js +237 -0
  25. package/dist/src/ai/index.d.ts +5 -0
  26. package/dist/src/ai/index.js +21 -0
  27. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  28. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  29. package/dist/src/index.d.ts +4 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/src/utils.d.ts +30 -0
  32. package/dist/src/utils.js +52 -0
  33. package/package.json +14 -2
  34. package/readme.md +425 -0
  35. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  36. package/spec/AgentRunner.spec.ts +527 -0
  37. package/spec/ConversationHooks.spec.ts +290 -0
  38. package/spec/FlinkAgent.spec.ts +310 -0
  39. package/spec/FlinkApp.onError.spec.ts +1 -2
  40. package/spec/StreamingIntegration.spec.ts +138 -0
  41. package/spec/SubAgentSupport.spec.ts +941 -0
  42. package/spec/ToolExecutor.spec.ts +360 -0
  43. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
  44. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
  45. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
  46. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
  47. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
  48. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
  49. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
  50. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
  51. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
  52. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  53. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  54. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
  55. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
  56. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
  57. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
  58. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
  59. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
  60. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
  61. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
  62. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
  63. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  64. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  65. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  66. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  67. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  68. package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
  69. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  70. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  71. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  72. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  73. package/spec/mock-project/dist/src/FlinkLog.js +26 -0
  74. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  75. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  76. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  77. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  78. package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
  79. package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
  80. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  81. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  82. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
  83. package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
  84. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  85. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  86. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  87. package/spec/mock-project/dist/src/index.js +17 -69
  88. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  89. package/spec/mock-project/dist/src/utils.js +290 -0
  90. package/spec/mock-project/tsconfig.json +6 -1
  91. package/spec/testHelpers.ts +49 -0
  92. package/spec/utils.caseConversion.spec.ts +80 -0
  93. package/spec/utils.spec.ts +13 -13
  94. package/src/FlinkApp.ts +251 -7
  95. package/src/FlinkContext.ts +22 -0
  96. package/src/FlinkHttpHandler.ts +100 -2
  97. package/src/TypeScriptCompiler.ts +398 -7
  98. package/src/TypeScriptUtils.ts +5 -0
  99. package/src/ai/AgentRunner.ts +549 -0
  100. package/src/ai/FlinkAgent.ts +770 -0
  101. package/src/ai/FlinkTool.ts +40 -0
  102. package/src/ai/LLMAdapter.ts +96 -0
  103. package/src/ai/SubAgentExecutor.ts +199 -0
  104. package/src/ai/ToolExecutor.ts +193 -0
  105. package/src/ai/index.ts +5 -0
  106. package/src/handlers/StreamWriterFactory.ts +84 -0
  107. package/src/index.ts +4 -0
  108. package/src/utils.ts +52 -0
  109. package/tsconfig.json +6 -1
@@ -0,0 +1,941 @@
1
+ import { FlinkAgent } from "../src/ai/FlinkAgent";
2
+ import { LLMAdapter } from "../src/ai/LLMAdapter";
3
+ import { SubAgentExecutor } from "../src/ai/SubAgentExecutor";
4
+ import { FlinkContext } from "../src/FlinkContext";
5
+ import { createStreamingMock } from "./testHelpers";
6
+
7
+ describe("Sub-Agent Support", () => {
8
+ let mockCtx: FlinkContext;
9
+ let mockLLMAdapter: LLMAdapter;
10
+
11
+ beforeEach(() => {
12
+ mockCtx = {
13
+ repos: {},
14
+ plugins: {},
15
+ agents: {},
16
+ };
17
+
18
+ // Mock LLM Adapter
19
+ mockLLMAdapter = createStreamingMock([
20
+ {
21
+ textContent: "Test response",
22
+ toolCalls: [],
23
+ usage: { inputTokens: 10, outputTokens: 20 },
24
+ stopReason: "end_turn" as const,
25
+ },
26
+ ]);
27
+ });
28
+
29
+ describe("SubAgentExecutor", () => {
30
+ it("should create sub-agent executor with correct properties", () => {
31
+ const executor = new SubAgentExecutor("carAgent", mockCtx);
32
+
33
+ expect(executor.getSubAgentId()).toBe("car-agent");
34
+ expect(executor.isSubAgentExecutor()).toBe(true);
35
+ });
36
+
37
+ it("should generate correct tool schema", () => {
38
+ // Create a mock sub-agent
39
+ class CarAgent extends FlinkAgent<FlinkContext> {
40
+ description = "Expert in cars";
41
+ instructions = "You know about cars";
42
+ tools: string[] = [];
43
+ }
44
+
45
+ const carAgent = new CarAgent();
46
+ (mockCtx as any).agents = { carAgent };
47
+
48
+ const executor = new SubAgentExecutor("carAgent", mockCtx);
49
+ const schema = executor.getToolSchema();
50
+
51
+ expect(schema.name).toBe("ask_car_agent");
52
+ expect(schema.description).toContain("car-agent");
53
+ expect(schema.description).toContain("Expert in cars");
54
+ expect(schema.input_schema).toBeDefined();
55
+ expect(schema.input_schema.properties.query).toBeDefined();
56
+ });
57
+
58
+ it("should run sub-agent successfully", async () => {
59
+ // Create mock sub-agent
60
+ class SpecialistAgent extends FlinkAgent<FlinkContext> {
61
+ description = "Specialist";
62
+ instructions = "You are a specialist";
63
+ tools: string[] = [];
64
+
65
+ setContext(ctx: FlinkContext) {
66
+ (this as any).ctx = ctx;
67
+ }
68
+
69
+ async answerQuestion(query: string) {
70
+ const response = this.run({ message: query });
71
+ return await response.result;
72
+ }
73
+ }
74
+
75
+ const specialist = new SpecialistAgent();
76
+ specialist.setContext(mockCtx);
77
+ specialist.__init(new Map([["default", mockLLMAdapter]]), {});
78
+ (mockCtx as any).agents = { specialistAgent: specialist };
79
+
80
+ const executor = new SubAgentExecutor("specialistAgent", mockCtx);
81
+
82
+ const result = await executor.execute({
83
+ query: "What is the answer?",
84
+ });
85
+
86
+ expect(result.success).toBe(true);
87
+ if (result.success) {
88
+ expect(result.data.message).toBe("Test response");
89
+ expect(result.data.stepsUsed).toBeGreaterThan(0);
90
+ }
91
+ });
92
+
93
+ it("should handle missing sub-agent gracefully", async () => {
94
+ (mockCtx as any).agents = {};
95
+
96
+ const executor = new SubAgentExecutor("missingAgent", mockCtx);
97
+
98
+ const result = await executor.execute({ query: "test" });
99
+
100
+ expect(result.success).toBe(false);
101
+ if (!result.success) {
102
+ expect(result.error).toContain("not found");
103
+ expect(result.code).toBe("AGENT_NOT_FOUND");
104
+ }
105
+ });
106
+
107
+ it("should propagate user context to sub-agent", async () => {
108
+ // Create sub-agent with permissions
109
+ class AdminAgent extends FlinkAgent<FlinkContext> {
110
+ description = "Admin-only agent";
111
+ instructions = "You handle admin tasks";
112
+ tools: string[] = [];
113
+ permissions = "admin";
114
+
115
+ setContext(ctx: FlinkContext) {
116
+ (this as any).ctx = ctx;
117
+ }
118
+ }
119
+
120
+ const adminAgent = new AdminAgent();
121
+ adminAgent.setContext(mockCtx);
122
+ adminAgent.__init(new Map([["default", mockLLMAdapter]]), {});
123
+ (mockCtx as any).agents = { adminAgent };
124
+
125
+ const executor = new SubAgentExecutor("adminAgent", mockCtx);
126
+
127
+ // Without permissions - should fail
128
+ const resultNoPerms = await executor.execute({ query: "test" }, { permissions: [] });
129
+ expect(resultNoPerms.success).toBe(false);
130
+
131
+ // With permissions - should succeed
132
+ const resultWithPerms = await executor.execute({ query: "test" }, { permissions: ["admin"] });
133
+ expect(resultWithPerms.success).toBe(true);
134
+ });
135
+
136
+ it("should format sub-agent result for AI", () => {
137
+ const executor = new SubAgentExecutor("testAgent", mockCtx);
138
+
139
+ const successResult = {
140
+ success: true as const,
141
+ data: {
142
+ message: "The answer is 42",
143
+ toolCalls: [],
144
+ stepsUsed: 2,
145
+ usage: { inputTokens: 50, outputTokens: 30 },
146
+ stoppedEarly: false,
147
+ },
148
+ };
149
+
150
+ const formatted = executor.formatResultForAI(successResult);
151
+ const parsed = JSON.parse(formatted);
152
+
153
+ expect(parsed.answer).toBe("The answer is 42");
154
+ expect(parsed.stepsUsed).toBe(2);
155
+ expect(parsed.tokensUsed).toBe(80);
156
+ });
157
+ });
158
+
159
+ describe("Agent with sub-agents", () => {
160
+ it("should support agents property with class references", () => {
161
+ class CarAgent extends FlinkAgent<FlinkContext> {
162
+ description = "Car expert";
163
+ instructions = "You know about cars";
164
+ tools: string[] = [];
165
+ }
166
+
167
+ class UserAgent extends FlinkAgent<FlinkContext> {
168
+ description = "User expert";
169
+ instructions = "You know about users";
170
+ tools: string[] = [];
171
+ }
172
+
173
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
174
+ description = "Orchestrator";
175
+ instructions = "You coordinate specialists";
176
+ tools: string[] = [];
177
+ agents = [CarAgent, UserAgent]; // ✅ Class references!
178
+ }
179
+
180
+ const orchestrator = new OrchestratorAgent();
181
+
182
+ expect(orchestrator.agents).toEqual([CarAgent, UserAgent]);
183
+ });
184
+
185
+ it("should support agents property with strings (backwards compatibility)", () => {
186
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
187
+ description = "Orchestrator";
188
+ instructions = "You coordinate specialists";
189
+ tools: string[] = [];
190
+ agents = ["car-agent", "user-agent"]; // ✅ Still works!
191
+ }
192
+
193
+ const orchestrator = new OrchestratorAgent();
194
+
195
+ expect(orchestrator.agents).toEqual(["car-agent", "user-agent"]);
196
+ });
197
+
198
+ it("should support mixed class and string references", () => {
199
+ class LocalAgent extends FlinkAgent<FlinkContext> {
200
+ description = "Local agent";
201
+ instructions = "Local";
202
+ tools: string[] = [];
203
+ }
204
+
205
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
206
+ description = "Orchestrator";
207
+ instructions = "You coordinate specialists";
208
+ tools: string[] = [];
209
+ agents = [LocalAgent, "external-agent"]; // ✅ Mix both!
210
+ }
211
+
212
+ const orchestrator = new OrchestratorAgent();
213
+
214
+ expect(orchestrator.agents).toEqual([LocalAgent, "external-agent"]);
215
+ });
216
+
217
+ it("should run orchestrator with sub-agent delegation", async () => {
218
+ // Create specialist agent
219
+ class SpecialistAgent extends FlinkAgent<FlinkContext> {
220
+ description = "Specialist";
221
+ instructions = "You are a specialist";
222
+ tools: string[] = [];
223
+
224
+ setContext(ctx: FlinkContext) {
225
+ (this as any).ctx = ctx;
226
+ }
227
+ }
228
+
229
+ // Create orchestrator agent
230
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
231
+ description = "Orchestrator";
232
+ instructions = "You delegate to specialists";
233
+ tools: string[] = [];
234
+ agents = [SpecialistAgent]; // ✅ Using class reference!
235
+
236
+ setContext(ctx: FlinkContext) {
237
+ (this as any).ctx = ctx;
238
+ }
239
+
240
+ async handleQuery(query: string) {
241
+ const response = this.run({ message: query });
242
+ return await response.result;
243
+ }
244
+ }
245
+
246
+ const specialist = new SpecialistAgent();
247
+ specialist.setContext(mockCtx);
248
+ // Initialize specialist with its own LLM adapter
249
+ specialist.__init(new Map([["default", mockLLMAdapter]]), {});
250
+
251
+ const orchestrator = new OrchestratorAgent();
252
+ orchestrator.setContext(mockCtx);
253
+
254
+ (mockCtx as any).agents = {
255
+ specialistAgent: specialist,
256
+ orchestratorAgent: orchestrator,
257
+ };
258
+
259
+ // Create sub-agent executor
260
+ const subAgentExecutor = new SubAgentExecutor("specialistAgent", mockCtx);
261
+
262
+ // Setup tools and LLM
263
+ const toolsMap = new Map([["ask_specialist_agent", subAgentExecutor as any]]);
264
+
265
+ // Mock LLM to delegate to specialist
266
+ const orchestratorMockAdapter = createStreamingMock([
267
+ // First: orchestrator decides to delegate
268
+ {
269
+ textContent: "I'll ask the specialist",
270
+ toolCalls: [
271
+ {
272
+ id: "1",
273
+ name: "ask_specialist_agent",
274
+ input: { query: "What is the answer?" },
275
+ },
276
+ ],
277
+ usage: { inputTokens: 50, outputTokens: 30 },
278
+ stopReason: "tool_use" as const,
279
+ },
280
+ // Second: specialist responds
281
+ {
282
+ textContent: "The specialist says 42",
283
+ toolCalls: [],
284
+ usage: { inputTokens: 30, outputTokens: 20 },
285
+ stopReason: "end_turn" as const,
286
+ },
287
+ // Third: orchestrator synthesizes
288
+ {
289
+ textContent: "Based on specialist: 42",
290
+ toolCalls: [],
291
+ usage: { inputTokens: 20, outputTokens: 15 },
292
+ stopReason: "end_turn" as const,
293
+ },
294
+ ]);
295
+
296
+ orchestrator.__init(new Map([["default", orchestratorMockAdapter]]), Object.fromEntries(toolsMap));
297
+
298
+ const result = await orchestrator.handleQuery("What is the answer?");
299
+
300
+ expect(result.message).toContain("42");
301
+ expect(result.toolCalls.length).toBeGreaterThan(0);
302
+
303
+ // Check that sub-agent call is marked
304
+ const subAgentCall = result.toolCalls.find((tc) => tc.isAgentCall);
305
+ expect(subAgentCall).toBeDefined();
306
+ expect(subAgentCall?.agentId).toBe("specialist-agent");
307
+
308
+ // Check sub-agent calls tracking
309
+ expect(result.subAgentCalls).toBeDefined();
310
+ expect(result.subAgentCalls?.length).toBeGreaterThan(0);
311
+ expect(result.subAgentCalls?.[0].agentId).toBe("specialist-agent");
312
+
313
+ // Token usage should be aggregated
314
+ expect(result.usage?.inputTokens).toBeGreaterThan(50);
315
+ expect(result.usage?.outputTokens).toBeGreaterThan(30);
316
+ });
317
+
318
+ it("should stream sub-agent events", async () => {
319
+ // Setup similar to previous test
320
+ class SpecialistAgent extends FlinkAgent<FlinkContext> {
321
+ description = "Specialist";
322
+ instructions = "You are a specialist";
323
+ tools: string[] = [];
324
+
325
+ setContext(ctx: FlinkContext) {
326
+ (this as any).ctx = ctx;
327
+ }
328
+ }
329
+
330
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
331
+ description = "Orchestrator";
332
+ instructions = "You delegate";
333
+ tools: string[] = [];
334
+ agents = [SpecialistAgent]; // ✅ Using class reference!
335
+
336
+ setContext(ctx: FlinkContext) {
337
+ (this as any).ctx = ctx;
338
+ }
339
+ }
340
+
341
+ // Create specialist with its own mock (simulates specialist answering)
342
+ const specialistMock = createStreamingMock([
343
+ {
344
+ textContent: "Answer from specialist",
345
+ toolCalls: [],
346
+ usage: { inputTokens: 10, outputTokens: 10 },
347
+ stopReason: "end_turn" as const,
348
+ },
349
+ ]);
350
+
351
+ const specialist = new SpecialistAgent();
352
+ specialist.setContext(mockCtx);
353
+ specialist.__init(new Map([["default", specialistMock]]), {});
354
+
355
+ const orchestrator = new OrchestratorAgent();
356
+ orchestrator.setContext(mockCtx);
357
+
358
+ (mockCtx as any).agents = {
359
+ specialistAgent: specialist,
360
+ orchestratorAgent: orchestrator,
361
+ };
362
+
363
+ const subAgentExecutor = new SubAgentExecutor("specialistAgent", mockCtx);
364
+
365
+ const toolsMap = new Map([["ask_specialist_agent", subAgentExecutor as any]]);
366
+
367
+ // Orchestrator mock that delegates to specialist
368
+ const orchestratorMock = createStreamingMock([
369
+ {
370
+ textContent: undefined,
371
+ toolCalls: [
372
+ {
373
+ id: "1",
374
+ name: "ask_specialist_agent",
375
+ input: { query: "test" },
376
+ },
377
+ ],
378
+ usage: { inputTokens: 10, outputTokens: 10 },
379
+ stopReason: "tool_use" as const,
380
+ },
381
+ {
382
+ textContent: "Final answer from orchestrator",
383
+ toolCalls: [],
384
+ usage: { inputTokens: 10, outputTokens: 10 },
385
+ stopReason: "end_turn" as const,
386
+ },
387
+ ]);
388
+
389
+ orchestrator.__init(new Map([["default", orchestratorMock]]), Object.fromEntries(toolsMap));
390
+
391
+ const response = orchestrator.run({ message: "test" });
392
+
393
+ const events: any[] = [];
394
+ for await (const chunk of response.fullStream) {
395
+ events.push(chunk);
396
+ }
397
+
398
+ // Should have agent_call_start and agent_call_result events
399
+ const startEvent = events.find((e) => e.type === "agent_call_start");
400
+ const resultEvent = events.find((e) => e.type === "agent_call_result");
401
+
402
+ expect(startEvent).toBeDefined();
403
+ expect(startEvent?.agentId).toBe("specialist-agent");
404
+ expect(startEvent?.input).toEqual({ query: "test" });
405
+
406
+ expect(resultEvent).toBeDefined();
407
+ expect(resultEvent?.agentId).toBe("specialist-agent");
408
+ expect(resultEvent?.result.message).toBe("Answer from specialist");
409
+
410
+ // Should also have text_delta events
411
+ const textEvents = events.filter((e) => e.type === "text_delta");
412
+ expect(textEvents.length).toBeGreaterThan(0);
413
+
414
+ // Should have complete event at the end
415
+ const completeEvent = events.find((e) => e.type === "complete");
416
+ expect(completeEvent).toBeDefined();
417
+ expect(completeEvent?.result.message).toBe("Final answer from orchestrator");
418
+ });
419
+
420
+ it("should handle multiple parallel sub-agent calls", async () => {
421
+ // Create two specialist agents
422
+ class CarAgent extends FlinkAgent<FlinkContext> {
423
+ description = "Car expert";
424
+ instructions = "You know about cars";
425
+ tools: string[] = [];
426
+ setContext(ctx: FlinkContext) {
427
+ (this as any).ctx = ctx;
428
+ }
429
+ }
430
+
431
+ class UserAgent extends FlinkAgent<FlinkContext> {
432
+ description = "User expert";
433
+ instructions = "You know about users";
434
+ tools: string[] = [];
435
+ setContext(ctx: FlinkContext) {
436
+ (this as any).ctx = ctx;
437
+ }
438
+ }
439
+
440
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
441
+ description = "Orchestrator";
442
+ instructions = "You coordinate";
443
+ tools: string[] = [];
444
+ agents = [CarAgent, UserAgent]; // ✅ Using class references!
445
+ setContext(ctx: FlinkContext) {
446
+ (this as any).ctx = ctx;
447
+ }
448
+ }
449
+
450
+ const carAgent = new CarAgent();
451
+ carAgent.setContext(mockCtx);
452
+ // Initialize car agent with its own LLM adapter
453
+ carAgent.__init(new Map([["default", mockLLMAdapter]]), {});
454
+
455
+ const userAgent = new UserAgent();
456
+ userAgent.setContext(mockCtx);
457
+ // Initialize user agent with its own LLM adapter
458
+ userAgent.__init(new Map([["default", mockLLMAdapter]]), {});
459
+
460
+ const orchestrator = new OrchestratorAgent();
461
+ orchestrator.setContext(mockCtx);
462
+
463
+ (mockCtx as any).agents = { carAgent, userAgent, orchestratorAgent: orchestrator };
464
+
465
+ const carExecutor = new SubAgentExecutor("carAgent", mockCtx);
466
+ const userExecutor = new SubAgentExecutor("userAgent", mockCtx);
467
+
468
+ const toolsMap = new Map([
469
+ ["ask_car_agent", carExecutor as any],
470
+ ["ask_user_agent", userExecutor as any],
471
+ ]);
472
+
473
+ // Mock LLM to call both agents in parallel (same turn)
474
+ const parallelMockAdapter = createStreamingMock([
475
+ {
476
+ textContent: undefined,
477
+ toolCalls: [
478
+ { id: "1", name: "ask_car_agent", input: { query: "cars?" } },
479
+ { id: "2", name: "ask_user_agent", input: { query: "users?" } },
480
+ ],
481
+ usage: { inputTokens: 20, outputTokens: 10 },
482
+ stopReason: "tool_use" as const,
483
+ },
484
+ // Car agent response
485
+ {
486
+ textContent: "Cars are vehicles",
487
+ toolCalls: [],
488
+ usage: { inputTokens: 10, outputTokens: 10 },
489
+ stopReason: "end_turn" as const,
490
+ },
491
+ // User agent response
492
+ {
493
+ textContent: "Users are people",
494
+ toolCalls: [],
495
+ usage: { inputTokens: 10, outputTokens: 10 },
496
+ stopReason: "end_turn" as const,
497
+ },
498
+ // Final synthesis
499
+ {
500
+ textContent: "Cars and users combined",
501
+ toolCalls: [],
502
+ usage: { inputTokens: 15, outputTokens: 10 },
503
+ stopReason: "end_turn" as const,
504
+ },
505
+ ]);
506
+
507
+ orchestrator.__init(new Map([["default", parallelMockAdapter]]), Object.fromEntries(toolsMap));
508
+
509
+ const result = await orchestrator.run({ message: "test" }).result;
510
+
511
+ // Should have 2 sub-agent calls
512
+ expect(result.subAgentCalls?.length).toBe(2);
513
+
514
+ const carCall = result.subAgentCalls?.find((c) => c.agentId === "car-agent");
515
+ const userCall = result.subAgentCalls?.find((c) => c.agentId === "user-agent");
516
+
517
+ expect(carCall).toBeDefined();
518
+ expect(userCall).toBeDefined();
519
+
520
+ // Token usage should aggregate from both sub-agents
521
+ const totalTokens = (result.usage?.inputTokens || 0) + (result.usage?.outputTokens || 0);
522
+ expect(totalTokens).toBeGreaterThan(50); // Base + both agents
523
+ });
524
+ });
525
+
526
+ describe("Sub-agent permissions", () => {
527
+ it("should respect sub-agent permissions", async () => {
528
+ class RestrictedAgent extends FlinkAgent<FlinkContext> {
529
+ description = "Restricted";
530
+ instructions = "Admin only";
531
+ tools: string[] = [];
532
+ permissions = "admin";
533
+ setContext(ctx: FlinkContext) {
534
+ (this as any).ctx = ctx;
535
+ }
536
+ }
537
+
538
+ class OrchestratorAgent extends FlinkAgent<FlinkContext> {
539
+ description = "Orchestrator";
540
+ instructions = "You delegate";
541
+ tools: string[] = [];
542
+ agents = [RestrictedAgent]; // ✅ Using class reference!
543
+ setContext(ctx: FlinkContext) {
544
+ (this as any).ctx = ctx;
545
+ }
546
+ }
547
+
548
+ const restricted = new RestrictedAgent();
549
+ restricted.setContext(mockCtx);
550
+
551
+ const orchestrator = new OrchestratorAgent();
552
+ orchestrator.setContext(mockCtx);
553
+
554
+ (mockCtx as any).agents = { restrictedAgent: restricted };
555
+
556
+ const subAgentExecutor = new SubAgentExecutor("restrictedAgent", mockCtx);
557
+
558
+ const toolsMap = new Map([["ask_restricted_agent", subAgentExecutor as any]]);
559
+
560
+ const restrictedMockAdapter = createStreamingMock([
561
+ {
562
+ textContent: undefined,
563
+ toolCalls: [
564
+ {
565
+ id: "1",
566
+ name: "ask_restricted_agent",
567
+ input: { query: "test" },
568
+ },
569
+ ],
570
+ usage: { inputTokens: 10, outputTokens: 10 },
571
+ stopReason: "tool_use" as const,
572
+ },
573
+ {
574
+ textContent: "Permission denied response",
575
+ toolCalls: [],
576
+ usage: { inputTokens: 10, outputTokens: 10 },
577
+ stopReason: "end_turn" as const,
578
+ },
579
+ ]);
580
+
581
+ orchestrator.__init(new Map([["default", restrictedMockAdapter]]), Object.fromEntries(toolsMap));
582
+
583
+ // Execute without admin permission
584
+ const response = orchestrator.withUser({ permissions: [] }).run({ message: "test" });
585
+
586
+ const result = await response.result;
587
+
588
+ // Sub-agent call should have failed
589
+ const subAgentCall = result.toolCalls.find((tc) => tc.isAgentCall);
590
+ expect(subAgentCall?.error).toBeDefined();
591
+ });
592
+ });
593
+
594
+ describe("Recursion depth limiting", () => {
595
+ it("should throw error when depth exceeds limit", async () => {
596
+ // Create simple agent with depth limit
597
+ class TestAgent extends FlinkAgent<FlinkContext> {
598
+ id = "test-agent";
599
+ description = "Test agent";
600
+ instructions = "You are a test agent";
601
+ tools: string[] = [];
602
+ limits = { maxSubAgentDepth: 3 }; // Low limit for testing
603
+ }
604
+
605
+ const agent = new TestAgent();
606
+ agent.ctx = mockCtx;
607
+ agent.__init(new Map([["default", mockLLMAdapter]]), {});
608
+
609
+ // Execute with depth already at limit (simulating deep sub-agent call)
610
+ const response = agent.run({
611
+ message: "test",
612
+ metadata: {
613
+ subAgentDepth: 4, // Already exceeds limit of 3
614
+ },
615
+ });
616
+
617
+ try {
618
+ await response.result;
619
+ fail("Should have thrown depth limit error");
620
+ } catch (err: any) {
621
+ expect(err.message).toContain("recursion depth limit exceeded");
622
+ expect(err.message).toContain("max: 3");
623
+ expect(err.message).toContain("current: 4");
624
+ }
625
+ });
626
+
627
+ it("should include delegation chain in depth limit error", async () => {
628
+ // Create agent with depth limit
629
+ class TestAgent extends FlinkAgent<FlinkContext> {
630
+ id = "test-agent";
631
+ description = "Test agent";
632
+ instructions = "You are a test agent";
633
+ tools: string[] = [];
634
+ limits = { maxSubAgentDepth: 3 };
635
+ }
636
+
637
+ const agent = new TestAgent();
638
+ agent.ctx = mockCtx;
639
+ agent.__init(new Map([["default", mockLLMAdapter]]), {});
640
+
641
+ // Simulate a delegation chain: orchestrator -> car-agent -> pricing-agent -> test-agent
642
+ const response = agent.run({
643
+ message: "test",
644
+ metadata: {
645
+ subAgentDepth: 4, // Exceeds limit of 3
646
+ parentAgentId: "pricing-agent",
647
+ parentMetadata: {
648
+ parentAgentId: "car-agent",
649
+ parentMetadata: {
650
+ parentAgentId: "orchestrator",
651
+ },
652
+ },
653
+ },
654
+ });
655
+
656
+ try {
657
+ await response.result;
658
+ fail("Should have thrown depth limit error");
659
+ } catch (err: any) {
660
+ expect(err.message).toContain("recursion depth limit exceeded");
661
+ expect(err.message).toContain("Delegation chain:");
662
+ expect(err.message).toContain("orchestrator");
663
+ expect(err.message).toContain("car-agent");
664
+ expect(err.message).toContain("pricing-agent");
665
+ expect(err.message).toContain("test-agent");
666
+ }
667
+ });
668
+
669
+ it("should respect custom maxSubAgentDepth limit", async () => {
670
+ // Create agents with declared dependencies
671
+ class AgentA extends FlinkAgent<FlinkContext> {
672
+ id = "agent-a";
673
+ description = "Agent A";
674
+ instructions = "You delegate to Agent B";
675
+ tools: string[] = [];
676
+ agents: any[] = []; // Will be populated after instantiation
677
+ limits = { maxSubAgentDepth: 2 }; // Custom limit
678
+ }
679
+
680
+ class AgentB extends FlinkAgent<FlinkContext> {
681
+ id = "agent-b";
682
+ description = "Agent B";
683
+ instructions = "You delegate to Agent A (creating circular delegation)";
684
+ tools: string[] = [];
685
+ agents: any[] = []; // Will be populated after instantiation
686
+ limits = { maxSubAgentDepth: 2 };
687
+ }
688
+
689
+ const agentA = new AgentA();
690
+ const agentB = new AgentB();
691
+
692
+ // Set up circular references after instantiation
693
+ agentA.agents = [AgentB];
694
+ agentB.agents = [AgentA];
695
+
696
+ agentA.ctx = mockCtx;
697
+ agentB.ctx = mockCtx;
698
+
699
+ // Each agent needs its own mock that always delegates back
700
+ // Agent A mock: always delegates to B (will be called multiple times)
701
+ const mockA = createStreamingMock([
702
+ // First call from root
703
+ {
704
+ textContent: undefined,
705
+ toolCalls: [
706
+ {
707
+ id: "tool_1",
708
+ name: "ask_agent_b",
709
+ input: { query: "delegate" },
710
+ },
711
+ ],
712
+ usage: { inputTokens: 10, outputTokens: 10 },
713
+ stopReason: "tool_use" as const,
714
+ },
715
+ // Second call (after B delegates back to A)
716
+ {
717
+ textContent: undefined,
718
+ toolCalls: [
719
+ {
720
+ id: "tool_2",
721
+ name: "ask_agent_b",
722
+ input: { query: "delegate again" },
723
+ },
724
+ ],
725
+ usage: { inputTokens: 10, outputTokens: 10 },
726
+ stopReason: "tool_use" as const,
727
+ },
728
+ ]);
729
+
730
+ // Agent B mock: always delegates to A
731
+ const mockB = createStreamingMock([
732
+ // First call from A
733
+ {
734
+ textContent: undefined,
735
+ toolCalls: [
736
+ {
737
+ id: "tool_3",
738
+ name: "ask_agent_a",
739
+ input: { query: "delegate back" },
740
+ },
741
+ ],
742
+ usage: { inputTokens: 10, outputTokens: 10 },
743
+ stopReason: "tool_use" as const,
744
+ },
745
+ // Second call
746
+ {
747
+ textContent: undefined,
748
+ toolCalls: [
749
+ {
750
+ id: "tool_4",
751
+ name: "ask_agent_a",
752
+ input: { query: "delegate back again" },
753
+ },
754
+ ],
755
+ usage: { inputTokens: 10, outputTokens: 10 },
756
+ stopReason: "tool_use" as const,
757
+ },
758
+ ]);
759
+
760
+ const toolsMapA = new Map([
761
+ ["ask_agent_b", new SubAgentExecutor("agentB", mockCtx)],
762
+ ]);
763
+ const toolsMapB = new Map([
764
+ ["ask_agent_a", new SubAgentExecutor("agentA", mockCtx)],
765
+ ]);
766
+
767
+ agentA.__init(new Map([["default", mockA]]), Object.fromEntries(toolsMapA) as any);
768
+ agentB.__init(new Map([["default", mockB]]), Object.fromEntries(toolsMapB) as any);
769
+
770
+ (mockCtx as any).agents = { agentA, agentB };
771
+
772
+ // Execute - should hit custom depth limit (2)
773
+ // A (depth 0) -> B (depth 1) -> A (depth 2) -> B (depth 3 - exceeds limit!)
774
+ const response = agentA.run({ message: "Start delegation" });
775
+
776
+ const result = await response.result;
777
+
778
+ // The depth limit error should be captured as a sub-agent tool error
779
+ // (not thrown as an exception, since sub-agent failures are graceful)
780
+ expect(result.toolCalls.length).toBeGreaterThan(0);
781
+
782
+ // Find the first sub-agent call (A -> B)
783
+ const firstBCall = result.toolCalls.find(tc => tc.isAgentCall && tc.agentId === "agent-b");
784
+ expect(firstBCall).toBeDefined();
785
+ expect(firstBCall?.error).toBeUndefined(); // First call should succeed
786
+
787
+ // B should have called A, and A should have tried to call B again (depth 3)
788
+ const bResult = firstBCall?.output as any;
789
+ expect(bResult.toolCalls).toBeDefined();
790
+
791
+ // Find A's call back to B within B's execution
792
+ const aCallsBack = bResult.toolCalls.find((tc: any) => tc.isAgentCall && tc.agentId === "agent-a");
793
+ expect(aCallsBack).toBeDefined();
794
+
795
+ // A's nested result should contain the depth limit error
796
+ const aNestedResult = aCallsBack.output as any;
797
+ const depthLimitError = aNestedResult?.toolCalls?.find((tc: any) =>
798
+ tc.error && tc.error.includes("recursion depth limit exceeded")
799
+ );
800
+
801
+ expect(depthLimitError).toBeDefined();
802
+ expect(depthLimitError?.error).toContain("max: 2");
803
+ expect(depthLimitError?.error).toContain("current: 3");
804
+ expect(depthLimitError?.error).toContain("Delegation chain");
805
+ });
806
+
807
+ it("should allow deep but finite delegation chains", async () => {
808
+ // Create a chain: A -> B -> C (depth 2, within default limit of 5)
809
+ // First declare all classes so they can reference each other
810
+ class AgentC extends FlinkAgent<FlinkContext> {
811
+ id = "agent-c";
812
+ description = "Agent C";
813
+ instructions = "You answer directly";
814
+ tools: string[] = [];
815
+ }
816
+
817
+ class AgentB extends FlinkAgent<FlinkContext> {
818
+ id = "agent-b";
819
+ description = "Agent B";
820
+ instructions = "You delegate to C";
821
+ tools: string[] = [];
822
+ agents = [AgentC]; // Declare dependency on C
823
+ }
824
+
825
+ class AgentA extends FlinkAgent<FlinkContext> {
826
+ id = "agent-a";
827
+ description = "Agent A";
828
+ instructions = "You delegate to B";
829
+ tools: string[] = [];
830
+ agents = [AgentB]; // Declare dependency on B
831
+ }
832
+
833
+ const agentA = new AgentA();
834
+ const agentB = new AgentB();
835
+ const agentC = new AgentC();
836
+
837
+ // A delegates to B
838
+ const mockA = createStreamingMock([
839
+ {
840
+ textContent: undefined,
841
+ toolCalls: [
842
+ {
843
+ id: "tool_1",
844
+ name: "ask_agent_b",
845
+ input: { query: "delegate to B" },
846
+ },
847
+ ],
848
+ usage: { inputTokens: 10, outputTokens: 10 },
849
+ stopReason: "tool_use" as const,
850
+ },
851
+ {
852
+ textContent: "Final from A",
853
+ toolCalls: [],
854
+ usage: { inputTokens: 10, outputTokens: 10 },
855
+ stopReason: "end_turn" as const,
856
+ },
857
+ ]);
858
+
859
+ // B delegates to C
860
+ const mockB = createStreamingMock([
861
+ {
862
+ textContent: undefined,
863
+ toolCalls: [
864
+ {
865
+ id: "tool_1",
866
+ name: "ask_agent_c",
867
+ input: { query: "delegate to C" },
868
+ },
869
+ ],
870
+ usage: { inputTokens: 10, outputTokens: 10 },
871
+ stopReason: "tool_use" as const,
872
+ },
873
+ {
874
+ textContent: "Response from B",
875
+ toolCalls: [],
876
+ usage: { inputTokens: 10, outputTokens: 10 },
877
+ stopReason: "end_turn" as const,
878
+ },
879
+ ]);
880
+
881
+ // C answers directly
882
+ const mockC = createStreamingMock([
883
+ {
884
+ textContent: "Answer from C",
885
+ toolCalls: [],
886
+ usage: { inputTokens: 10, outputTokens: 10 },
887
+ stopReason: "end_turn" as const,
888
+ },
889
+ ]);
890
+
891
+ agentA.ctx = mockCtx;
892
+ agentB.ctx = mockCtx;
893
+ agentC.ctx = mockCtx;
894
+
895
+ const toolsMapA = new Map([
896
+ ["ask_agent_b", new SubAgentExecutor("agentB", mockCtx)],
897
+ ]);
898
+ const toolsMapB = new Map([
899
+ ["ask_agent_c", new SubAgentExecutor("agentC", mockCtx)],
900
+ ]);
901
+
902
+ agentA.__init(new Map([["default", mockA]]), Object.fromEntries(toolsMapA) as any);
903
+ agentB.__init(new Map([["default", mockB]]), Object.fromEntries(toolsMapB) as any);
904
+ agentC.__init(new Map([["default", mockC]]), {});
905
+
906
+ (mockCtx as any).agents = { agentA, agentB, agentC };
907
+
908
+ // Execute - should succeed (depth 2 < default limit 5)
909
+ // A (depth 0) -> B (depth 1) -> C (depth 2) - all within limit
910
+ const response = agentA.run({ message: "Start chain" });
911
+ const result = await response.result;
912
+
913
+ expect(result.message).toBe("Final from A");
914
+ expect(result.subAgentCalls).toBeDefined();
915
+ expect(result.subAgentCalls?.length).toBe(1);
916
+
917
+ // Verify the delegation chain worked correctly
918
+ const bCall = result.subAgentCalls?.[0];
919
+ expect(bCall).toBeDefined();
920
+ expect(bCall?.agentId).toBe("agent-b");
921
+ expect(bCall?.result.message).toBe("Response from B");
922
+
923
+ // Agent B should have called Agent C (nested sub-agent call)
924
+ expect(bCall?.result.subAgentCalls).toBeDefined();
925
+ expect(bCall?.result.subAgentCalls?.length).toBe(1);
926
+
927
+ const cCall = bCall?.result.subAgentCalls?.[0];
928
+ expect(cCall).toBeDefined();
929
+ expect(cCall?.agentId).toBe("agent-c");
930
+ expect(cCall?.result.message).toBe("Answer from C");
931
+
932
+ // Token usage should be aggregated from all agents
933
+ // A: 2 calls (delegate + final) = 20 input + 20 output
934
+ // B: 2 calls (delegate + final) = 20 input + 20 output
935
+ // C: 1 call (answer) = 10 input + 10 output
936
+ // Total: 50 input + 50 output
937
+ expect(result.usage?.inputTokens).toBe(50);
938
+ expect(result.usage?.outputTokens).toBe(50);
939
+ });
940
+ });
941
+ });