@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.
- package/CHANGELOG.md +6 -0
- package/cli/build.ts +8 -1
- package/cli/run.ts +8 -1
- package/dist/cli/build.js +8 -1
- package/dist/cli/run.js +8 -1
- package/dist/src/FlinkApp.d.ts +33 -0
- package/dist/src/FlinkApp.js +247 -27
- package/dist/src/FlinkContext.d.ts +21 -0
- package/dist/src/FlinkHttpHandler.d.ts +90 -1
- package/dist/src/TypeScriptCompiler.d.ts +42 -0
- package/dist/src/TypeScriptCompiler.js +346 -4
- package/dist/src/TypeScriptUtils.js +4 -0
- package/dist/src/ai/AgentRunner.d.ts +39 -0
- package/dist/src/ai/AgentRunner.js +625 -0
- package/dist/src/ai/FlinkAgent.d.ts +446 -0
- package/dist/src/ai/FlinkAgent.js +633 -0
- package/dist/src/ai/FlinkTool.d.ts +37 -0
- package/dist/src/ai/FlinkTool.js +2 -0
- package/dist/src/ai/LLMAdapter.d.ts +119 -0
- package/dist/src/ai/LLMAdapter.js +2 -0
- package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
- package/dist/src/ai/SubAgentExecutor.js +220 -0
- package/dist/src/ai/ToolExecutor.d.ts +35 -0
- package/dist/src/ai/ToolExecutor.js +237 -0
- package/dist/src/ai/index.d.ts +5 -0
- package/dist/src/ai/index.js +21 -0
- package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
- package/dist/src/handlers/StreamWriterFactory.js +83 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/utils.d.ts +30 -0
- package/dist/src/utils.js +52 -0
- package/package.json +14 -2
- package/readme.md +425 -0
- package/spec/AgentDuplicateDetection.spec.ts +112 -0
- package/spec/AgentRunner.spec.ts +527 -0
- package/spec/ConversationHooks.spec.ts +290 -0
- package/spec/FlinkAgent.spec.ts +310 -0
- package/spec/FlinkApp.onError.spec.ts +1 -2
- package/spec/StreamingIntegration.spec.ts +138 -0
- package/spec/SubAgentSupport.spec.ts +941 -0
- package/spec/ToolExecutor.spec.ts +360 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
- package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
- package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
- package/spec/mock-project/dist/src/FlinkContext.js +2 -0
- package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
- package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
- package/spec/mock-project/dist/src/FlinkJob.js +2 -0
- package/spec/mock-project/dist/src/FlinkLog.js +26 -0
- package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
- package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
- package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
- package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
- package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
- package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
- package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
- package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
- package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
- package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
- package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
- package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
- package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
- package/spec/mock-project/dist/src/index.js +17 -69
- package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
- package/spec/mock-project/dist/src/utils.js +290 -0
- package/spec/mock-project/tsconfig.json +6 -1
- package/spec/testHelpers.ts +49 -0
- package/spec/utils.caseConversion.spec.ts +80 -0
- package/spec/utils.spec.ts +13 -13
- package/src/FlinkApp.ts +251 -7
- package/src/FlinkContext.ts +22 -0
- package/src/FlinkHttpHandler.ts +100 -2
- package/src/TypeScriptCompiler.ts +398 -7
- package/src/TypeScriptUtils.ts +5 -0
- package/src/ai/AgentRunner.ts +549 -0
- package/src/ai/FlinkAgent.ts +770 -0
- package/src/ai/FlinkTool.ts +40 -0
- package/src/ai/LLMAdapter.ts +96 -0
- package/src/ai/SubAgentExecutor.ts +199 -0
- package/src/ai/ToolExecutor.ts +193 -0
- package/src/ai/index.ts +5 -0
- package/src/handlers/StreamWriterFactory.ts +84 -0
- package/src/index.ts +4 -0
- package/src/utils.ts +52 -0
- 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
|
+
});
|