@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
package/readme.md
CHANGED
|
@@ -218,6 +218,431 @@ export const Props: RouteProps {
|
|
|
218
218
|
}
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
+
### AI Agents
|
|
222
|
+
|
|
223
|
+
Flink includes a comprehensive AI agents and tools framework for building LLM-powered applications with multi-agent orchestration, streaming responses, and type-safe tool execution.
|
|
224
|
+
|
|
225
|
+
Agents are placed in `src/agents/` folder and extend the `FlinkAgent` base class. They are automatically registered and available via the context.
|
|
226
|
+
|
|
227
|
+
#### Creating an Agent
|
|
228
|
+
|
|
229
|
+
Agents extend `FlinkAgent<Ctx>` and define their configuration as class properties:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// src/agents/CarAgent.ts
|
|
233
|
+
import { FlinkAgent } from "@flink-app/flink";
|
|
234
|
+
import AppCtx from "../AppCtx";
|
|
235
|
+
import GetCarsTool from "../tools/GetCarsTool";
|
|
236
|
+
|
|
237
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
238
|
+
id = "car-agent"; // Optional: defaults to "car-agent" (kebab-case class name)
|
|
239
|
+
description = "Expert in car models and specifications";
|
|
240
|
+
instructions = "You are a knowledgeable car expert...";
|
|
241
|
+
tools = [GetCarsTool]; // Tool class references
|
|
242
|
+
|
|
243
|
+
model = {
|
|
244
|
+
adapterId: "default", // References registered LLM adapter
|
|
245
|
+
maxTokens: 2000,
|
|
246
|
+
temperature: 0.7,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
limits = {
|
|
250
|
+
maxSteps: 10, // Maximum execution steps
|
|
251
|
+
maxSubAgentDepth: 5, // Prevents infinite recursion (default: 5)
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Define domain-specific methods for better type safety
|
|
255
|
+
async searchByBrand(brand: string) {
|
|
256
|
+
const response = this.execute({
|
|
257
|
+
message: `Find all ${brand} cars`
|
|
258
|
+
});
|
|
259
|
+
return await response.result;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### Creating Tools
|
|
265
|
+
|
|
266
|
+
Tools define actions that agents can perform. They use Zod for type-safe input/output validation:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
// src/tools/GetCarsTool.ts
|
|
270
|
+
import { FlinkTool, FlinkToolProps, ToolResult } from "@flink-app/flink";
|
|
271
|
+
import { z } from "zod";
|
|
272
|
+
import AppCtx from "../AppCtx";
|
|
273
|
+
|
|
274
|
+
export const Tool: FlinkToolProps = {
|
|
275
|
+
id: "get-cars-tool",
|
|
276
|
+
description: "Search for cars in inventory",
|
|
277
|
+
inputSchema: z.object({
|
|
278
|
+
brand: z.string().optional(),
|
|
279
|
+
maxPrice: z.number().optional(),
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const GetCarsTool: FlinkTool<AppCtx, z.infer<typeof Tool.inputSchema>> = async ({
|
|
284
|
+
input,
|
|
285
|
+
ctx
|
|
286
|
+
}) => {
|
|
287
|
+
const cars = await ctx.repos.carRepo.findAll({
|
|
288
|
+
brand: input.brand,
|
|
289
|
+
price: { $lte: input.maxPrice }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return { success: true, data: cars };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export default GetCarsTool;
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
#### Multi-Agent Orchestration
|
|
299
|
+
|
|
300
|
+
Agents can delegate to specialized sub-agents using class references:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// src/agents/OrchestratorAgent.ts
|
|
304
|
+
import { FlinkAgent } from "@flink-app/flink";
|
|
305
|
+
import AppCtx from "../AppCtx";
|
|
306
|
+
import CarAgent from "./CarAgent";
|
|
307
|
+
import PricingAgent from "./PricingAgent";
|
|
308
|
+
|
|
309
|
+
export default class OrchestratorAgent extends FlinkAgent<AppCtx> {
|
|
310
|
+
description = "Coordinates between specialized agents";
|
|
311
|
+
instructions = "Route queries to appropriate specialists...";
|
|
312
|
+
tools = []; // No direct tools, only sub-agents
|
|
313
|
+
agents = [CarAgent, PricingAgent]; // Type-safe class references!
|
|
314
|
+
|
|
315
|
+
limits = {
|
|
316
|
+
maxSubAgentDepth: 3, // Prevent infinite delegation loops
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Recursion Protection**: The `maxSubAgentDepth` limit (default: 5) prevents infinite loops when agents delegate to each other. If Agent A calls Agent B, which calls Agent A again, the framework will throw an error when the depth limit is exceeded, preventing stack overflow and infinite execution.
|
|
322
|
+
|
|
323
|
+
#### Using Agents in Handlers
|
|
324
|
+
|
|
325
|
+
Access agents through the context and optionally bind a user for permissions:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// src/handlers/car/PostCarQuery.ts
|
|
329
|
+
import { Handler } from "@flink-app/flink";
|
|
330
|
+
import AppCtx from "../../AppCtx";
|
|
331
|
+
|
|
332
|
+
const PostCarQuery: Handler<AppCtx, { question: string }, { answer: string }> = async ({
|
|
333
|
+
ctx,
|
|
334
|
+
req
|
|
335
|
+
}) => {
|
|
336
|
+
const result = await ctx.agents.carAgent
|
|
337
|
+
.withUser(req.user)
|
|
338
|
+
.searchByBrand(req.body.question);
|
|
339
|
+
|
|
340
|
+
return { data: { answer: result.message } };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export default PostCarQuery;
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
#### Streaming Responses
|
|
347
|
+
|
|
348
|
+
Agents support real-time streaming for better UX:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// src/handlers/car/PostCarQueryStream.ts
|
|
352
|
+
import { Handler } from "@flink-app/flink";
|
|
353
|
+
import AppCtx from "../../AppCtx";
|
|
354
|
+
|
|
355
|
+
const PostCarQueryStream: Handler<AppCtx, { question: string }> = async ({
|
|
356
|
+
ctx,
|
|
357
|
+
req,
|
|
358
|
+
res
|
|
359
|
+
}) => {
|
|
360
|
+
const response = ctx.agents.carAgent.execute({
|
|
361
|
+
message: req.body.question
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
365
|
+
|
|
366
|
+
for await (const text of response.textStream) {
|
|
367
|
+
res.write(`data: ${text}\n\n`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
res.end();
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export default PostCarQueryStream;
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
#### AI Permissions
|
|
377
|
+
|
|
378
|
+
Flink provides a flexible permission system for agents and tools that integrates with authentication plugins. Starting in v0.15.0, auth plugins populate `req.userPermissions` with resolved permissions based on roles, dynamic roles, or custom logic.
|
|
379
|
+
|
|
380
|
+
**Three Permission Patterns:**
|
|
381
|
+
|
|
382
|
+
1. **String-based**: Single required permission
|
|
383
|
+
2. **Array-based**: Multiple required permissions (ALL required, AND logic)
|
|
384
|
+
3. **Function-based**: Custom permission logic with async support
|
|
385
|
+
|
|
386
|
+
**Agent Permissions Example:**
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
390
|
+
description = "Expert in car models";
|
|
391
|
+
instructions = "You are a car expert...";
|
|
392
|
+
tools = ["get-cars-tool", "create-car-tool"];
|
|
393
|
+
permissions = "car:access"; // Single permission required
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Tool Permissions Example:**
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
// String-based: Simple permission check
|
|
401
|
+
export const Tool: FlinkToolProps = {
|
|
402
|
+
id: "create-car",
|
|
403
|
+
description: "Create a new car",
|
|
404
|
+
permissions: "car:create", // User must have this permission
|
|
405
|
+
inputSchema: z.object({
|
|
406
|
+
brand: z.string(),
|
|
407
|
+
model: z.string(),
|
|
408
|
+
}),
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Array-based: Multiple permissions required (AND logic)
|
|
412
|
+
export const Tool: FlinkToolProps = {
|
|
413
|
+
id: "premium-report",
|
|
414
|
+
description: "Generate premium analytics",
|
|
415
|
+
permissions: ["car:read", "premium-features"], // ALL required
|
|
416
|
+
inputSchema: z.object({
|
|
417
|
+
carId: z.string(),
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Function-based: Custom logic (async supported)
|
|
422
|
+
export const Tool: FlinkToolProps = {
|
|
423
|
+
id: "delete-car",
|
|
424
|
+
description: "Delete a car (admin or owner only)",
|
|
425
|
+
permissions: async (input, user) => {
|
|
426
|
+
// Admin can delete any car
|
|
427
|
+
if (user?.permissions?.includes("admin")) return true;
|
|
428
|
+
|
|
429
|
+
// Owner can delete their own car
|
|
430
|
+
const car = await carRepo.getById(input.carId);
|
|
431
|
+
return car.ownerId === user?.id;
|
|
432
|
+
},
|
|
433
|
+
inputSchema: z.object({
|
|
434
|
+
carId: z.string(),
|
|
435
|
+
}),
|
|
436
|
+
};
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
**Using Permissions in Handlers:**
|
|
440
|
+
|
|
441
|
+
Auth plugins (like JWT Auth Plugin) populate `req.userPermissions` during authentication. Pass this to agents for automatic permission checks:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
const handler: Handler<AppCtx, RequestBody, ResponseBody> = async ({ req, ctx }) => {
|
|
445
|
+
const agent = ctx.agents.carAgent
|
|
446
|
+
.withUser(req.user)
|
|
447
|
+
.withPermissions(req.userPermissions); // Pass resolved permissions
|
|
448
|
+
|
|
449
|
+
return await agent.execute({ message: req.body.message });
|
|
450
|
+
};
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
**How `userPermissions` Works:**
|
|
454
|
+
|
|
455
|
+
Auth plugins automatically populate `req.userPermissions` based on their configuration:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
// Strategy 1: Direct permissions from user object
|
|
459
|
+
jwtAuthPlugin({
|
|
460
|
+
secret: process.env.JWT_SECRET,
|
|
461
|
+
getUser: async (tokenData) => {
|
|
462
|
+
const user = await userRepo.getById(tokenData.userId);
|
|
463
|
+
return {
|
|
464
|
+
id: user.id,
|
|
465
|
+
permissions: user.permissions, // Direct permissions array
|
|
466
|
+
};
|
|
467
|
+
},
|
|
468
|
+
rolePermissions: {},
|
|
469
|
+
});
|
|
470
|
+
// Result: req.userPermissions = user.permissions
|
|
471
|
+
|
|
472
|
+
// Strategy 2: Static role mapping
|
|
473
|
+
jwtAuthPlugin({
|
|
474
|
+
secret: process.env.JWT_SECRET,
|
|
475
|
+
getUser: async (tokenData) => {
|
|
476
|
+
return { id: tokenData.userId };
|
|
477
|
+
},
|
|
478
|
+
rolePermissions: {
|
|
479
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
480
|
+
user: ["car:read"],
|
|
481
|
+
premium: ["car:read", "car:create", "premium-features"],
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
// JWT token has roles: ["user", "premium"]
|
|
485
|
+
// Result: req.userPermissions = ["car:read", "car:create", "premium-features"]
|
|
486
|
+
|
|
487
|
+
// Strategy 3: Dynamic roles (multi-tenant)
|
|
488
|
+
jwtAuthPlugin({
|
|
489
|
+
secret: process.env.JWT_SECRET,
|
|
490
|
+
useDynamicRoles: true, // Enable dynamic role resolution
|
|
491
|
+
getUser: async (tokenData, req) => {
|
|
492
|
+
const orgId = req.headers['x-organization-id'];
|
|
493
|
+
const membership = await orgMembershipRepo.getByUserAndOrg(
|
|
494
|
+
tokenData.userId,
|
|
495
|
+
orgId
|
|
496
|
+
);
|
|
497
|
+
return {
|
|
498
|
+
id: tokenData.userId,
|
|
499
|
+
roles: [membership.role], // Org-specific role
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
rolePermissions: {
|
|
503
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
504
|
+
user: ["car:read"],
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
// Same user, different orgs → different permissions
|
|
508
|
+
// Org A: admin role → ["car:read", "car:create", "car:delete"]
|
|
509
|
+
// Org B: user role → ["car:read"]
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**Permission Benefits:**
|
|
513
|
+
|
|
514
|
+
- ✅ **Performance**: Permissions computed once per request (not per tool use)
|
|
515
|
+
- ✅ **Tool Filtering**: LLM only sees tools user has access to (prevents hallucination)
|
|
516
|
+
- ✅ **Consistent**: Same permission logic across handlers, agents, and tools
|
|
517
|
+
- ✅ **Flexible**: String/array for simple cases, function for complex logic
|
|
518
|
+
- ✅ **Multi-tenant**: Dynamic roles support org-specific permissions
|
|
519
|
+
|
|
520
|
+
**Backward Compatibility:**
|
|
521
|
+
|
|
522
|
+
The system is fully backward compatible:
|
|
523
|
+
- If `req.userPermissions` is not set → falls back to `user.permissions`
|
|
524
|
+
- Function-based permissions continue to work as before
|
|
525
|
+
- No breaking changes to existing apps
|
|
526
|
+
|
|
527
|
+
#### Registering LLM Adapters
|
|
528
|
+
|
|
529
|
+
LLM adapters are configured in `FlinkOptions.ai.llms`:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// src/index.ts
|
|
533
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
534
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
535
|
+
import { AnthropicAdapter } from "@flink-app/anthropic-adapter";
|
|
536
|
+
import AppCtx from "./AppCtx";
|
|
537
|
+
|
|
538
|
+
const app = new FlinkApp<AppCtx>({
|
|
539
|
+
name: "My App",
|
|
540
|
+
ai: {
|
|
541
|
+
llms: {
|
|
542
|
+
default: new AnthropicAdapter(
|
|
543
|
+
new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
|
|
544
|
+
"claude-3-5-sonnet-20241022"
|
|
545
|
+
),
|
|
546
|
+
fast: new AnthropicAdapter(
|
|
547
|
+
new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
|
|
548
|
+
"claude-3-haiku-20240307"
|
|
549
|
+
),
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
app.start();
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### Custom LLM Adapters
|
|
558
|
+
|
|
559
|
+
Create custom adapters by implementing the `LLMAdapter` interface:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
import { LLMAdapter, LLMStreamChunk } from "@flink-app/flink";
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Custom LLM adapter implementation
|
|
566
|
+
*
|
|
567
|
+
* Note: LLM adapters are streaming-only. Flink's AgentRunner only calls stream().
|
|
568
|
+
* The streaming approach provides better UX (time-to-first-token <500ms) and
|
|
569
|
+
* FlinkAgent's lazy generator supports both streaming and non-streaming consumption.
|
|
570
|
+
*/
|
|
571
|
+
export class CustomAdapter implements LLMAdapter {
|
|
572
|
+
constructor(private client: any, private model: string) {}
|
|
573
|
+
|
|
574
|
+
async *stream(params: {
|
|
575
|
+
instructions: string;
|
|
576
|
+
messages: any[];
|
|
577
|
+
tools: any[];
|
|
578
|
+
maxTokens: number;
|
|
579
|
+
temperature: number;
|
|
580
|
+
}): AsyncGenerator<LLMStreamChunk> {
|
|
581
|
+
const stream = await this.client.chat.completions.create({
|
|
582
|
+
model: this.model,
|
|
583
|
+
messages: [
|
|
584
|
+
{ role: "system", content: params.instructions },
|
|
585
|
+
...params.messages
|
|
586
|
+
],
|
|
587
|
+
tools: params.tools,
|
|
588
|
+
max_tokens: params.maxTokens,
|
|
589
|
+
temperature: params.temperature,
|
|
590
|
+
stream: true,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
let inputTokens = 0;
|
|
594
|
+
let outputTokens = 0;
|
|
595
|
+
|
|
596
|
+
for await (const chunk of stream) {
|
|
597
|
+
const delta = chunk.choices[0]?.delta;
|
|
598
|
+
|
|
599
|
+
// Stream text deltas
|
|
600
|
+
if (delta?.content) {
|
|
601
|
+
yield { type: "text", delta: delta.content };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Stream tool calls
|
|
605
|
+
if (delta?.tool_calls) {
|
|
606
|
+
for (const toolCall of delta.tool_calls) {
|
|
607
|
+
yield {
|
|
608
|
+
type: "tool_call",
|
|
609
|
+
toolCall: {
|
|
610
|
+
id: toolCall.id,
|
|
611
|
+
name: toolCall.function.name,
|
|
612
|
+
input: JSON.parse(toolCall.function.arguments),
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Track usage
|
|
619
|
+
if (chunk.usage) {
|
|
620
|
+
inputTokens = chunk.usage.prompt_tokens;
|
|
621
|
+
outputTokens = chunk.usage.completion_tokens;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Emit usage
|
|
626
|
+
yield {
|
|
627
|
+
type: "usage",
|
|
628
|
+
usage: { inputTokens, outputTokens },
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Emit done
|
|
632
|
+
yield {
|
|
633
|
+
type: "done",
|
|
634
|
+
stopReason: "end_turn",
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
#### Available Packages
|
|
641
|
+
|
|
642
|
+
- `@flink-app/flink` - Core framework with AI agents & tools
|
|
643
|
+
- `@flink-app/anthropic-adapter` - Official Anthropic Claude adapter
|
|
644
|
+
- `@flink-app/fake-llm-adapter` - Deterministic testing adapter
|
|
645
|
+
|
|
221
646
|
## 🤕 Known issues
|
|
222
647
|
|
|
223
648
|
- Current TypeScript compiler is slow when project gets larger
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { toKebabCase } from "../src/utils";
|
|
2
|
+
|
|
3
|
+
describe("Agent Duplicate Detection Logic", () => {
|
|
4
|
+
describe("Instance name collisions (camelCase)", () => {
|
|
5
|
+
// Instance names are derived by lowercasing first character
|
|
6
|
+
const getInstanceName = (className: string) => {
|
|
7
|
+
return className.charAt(0).toLowerCase() + className.substring(1);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
it("should show CarAgent vs Caragent do NOT collide (different casing)", () => {
|
|
11
|
+
const instance1 = getInstanceName("CarAgent");
|
|
12
|
+
const instance2 = getInstanceName("Caragent");
|
|
13
|
+
|
|
14
|
+
// CarAgent → carAgent, Caragent → caragent (no collision)
|
|
15
|
+
expect(instance1).toBe("carAgent");
|
|
16
|
+
expect(instance2).toBe("caragent");
|
|
17
|
+
expect(instance1).not.toBe(instance2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should detect collision example: CarAgent vs CARAgent", () => {
|
|
21
|
+
// This is a contrived example, but shows the logic
|
|
22
|
+
const instance1 = getInstanceName("CarAgent");
|
|
23
|
+
const instance2 = getInstanceName("CarAgent"); // Exact same name
|
|
24
|
+
|
|
25
|
+
// Both become "carAgent" - COLLISION!
|
|
26
|
+
expect(instance1).toBe("carAgent");
|
|
27
|
+
expect(instance2).toBe("carAgent");
|
|
28
|
+
expect(instance1).toBe(instance2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should not collide for different class names", () => {
|
|
32
|
+
const instance1 = getInstanceName("CarAgent");
|
|
33
|
+
const instance2 = getInstanceName("BikeAgent");
|
|
34
|
+
|
|
35
|
+
expect(instance1).toBe("carAgent");
|
|
36
|
+
expect(instance2).toBe("bikeAgent");
|
|
37
|
+
expect(instance1).not.toBe(instance2);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("Agent ID collisions (kebab-case)", () => {
|
|
42
|
+
it("should detect APIAgent vs ApiAgent collision", () => {
|
|
43
|
+
const id1 = toKebabCase("APIAgent");
|
|
44
|
+
const id2 = toKebabCase("ApiAgent");
|
|
45
|
+
|
|
46
|
+
// Both become "api-agent" - COLLISION!
|
|
47
|
+
expect(id1).toBe("api-agent");
|
|
48
|
+
expect(id2).toBe("api-agent");
|
|
49
|
+
expect(id1).toBe(id2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should detect HTMLParser vs HtmlParser collision", () => {
|
|
53
|
+
const id1 = toKebabCase("HTMLParser");
|
|
54
|
+
const id2 = toKebabCase("HtmlParser");
|
|
55
|
+
|
|
56
|
+
// Both become "html-parser" - COLLISION!
|
|
57
|
+
expect(id1).toBe("html-parser");
|
|
58
|
+
expect(id2).toBe("html-parser");
|
|
59
|
+
expect(id1).toBe(id2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should detect XMLHttpRequest vs XmlHttpRequest collision", () => {
|
|
63
|
+
const id1 = toKebabCase("XMLHttpRequest");
|
|
64
|
+
const id2 = toKebabCase("XmlHttpRequest");
|
|
65
|
+
|
|
66
|
+
// Both become "xml-http-request" - COLLISION!
|
|
67
|
+
expect(id1).toBe("xml-http-request");
|
|
68
|
+
expect(id2).toBe("xml-http-request");
|
|
69
|
+
expect(id1).toBe(id2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should not collide for different agent names", () => {
|
|
73
|
+
const id1 = toKebabCase("CarAgent");
|
|
74
|
+
const id2 = toKebabCase("BikeAgent");
|
|
75
|
+
|
|
76
|
+
expect(id1).toBe("car-agent");
|
|
77
|
+
expect(id2).toBe("bike-agent");
|
|
78
|
+
expect(id1).not.toBe(id2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should not collide for similar but distinct names", () => {
|
|
82
|
+
const id1 = toKebabCase("CarAgent");
|
|
83
|
+
const id2 = toKebabCase("CarPricingAgent");
|
|
84
|
+
|
|
85
|
+
expect(id1).toBe("car-agent");
|
|
86
|
+
expect(id2).toBe("car-pricing-agent");
|
|
87
|
+
expect(id1).not.toBe(id2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("Real-world collision scenarios", () => {
|
|
92
|
+
it("should show that explicit IDs prevent collisions", () => {
|
|
93
|
+
// Even if class names would collide, explicit IDs prevent issues
|
|
94
|
+
const explicitId1 = "api-agent-v1";
|
|
95
|
+
const explicitId2 = "api-agent-v2";
|
|
96
|
+
|
|
97
|
+
expect(explicitId1).not.toBe(explicitId2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should demonstrate the collision resolution path", () => {
|
|
101
|
+
// Problem: Both APIAgent and ApiAgent → "api-agent"
|
|
102
|
+
const problematicId1 = toKebabCase("APIAgent");
|
|
103
|
+
const problematicId2 = toKebabCase("ApiAgent");
|
|
104
|
+
expect(problematicId1).toBe(problematicId2); // Collision!
|
|
105
|
+
|
|
106
|
+
// Solution: Use explicit IDs
|
|
107
|
+
const resolvedId1 = "api-agent-v1";
|
|
108
|
+
const resolvedId2 = "api-agent-v2";
|
|
109
|
+
expect(resolvedId1).not.toBe(resolvedId2); // No collision
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|