@flink-app/flink 0.14.3 → 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 +66 -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 +279 -35
- package/dist/src/FlinkContext.d.ts +21 -0
- package/dist/src/FlinkHttpHandler.d.ts +152 -9
- package/dist/src/FlinkHttpHandler.js +37 -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 +16 -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/FlinkApp.query.spec.ts +107 -0
- package/spec/FlinkApp.validationMode.spec.ts +155 -0
- 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 +275 -8
- package/src/FlinkContext.ts +22 -0
- package/src/FlinkHttpHandler.ts +164 -10
- 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/src/FlinkApp.ts
CHANGED
|
@@ -9,15 +9,20 @@ import morgan from "morgan";
|
|
|
9
9
|
import ms from "ms";
|
|
10
10
|
import { AsyncTask, CronJob, SimpleIntervalJob, ToadScheduler } from "toad-scheduler";
|
|
11
11
|
import { v4 } from "uuid";
|
|
12
|
+
import { FlinkAgentFile } from "./ai/FlinkAgent";
|
|
13
|
+
import { FlinkToolFile } from "./ai/FlinkTool";
|
|
14
|
+
import { LLMAdapter } from "./ai/LLMAdapter";
|
|
15
|
+
import { ToolExecutor } from "./ai/ToolExecutor";
|
|
12
16
|
import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
|
|
13
17
|
import { FlinkContext } from "./FlinkContext";
|
|
14
18
|
import { FlinkError, internalServerError, notFound, unauthorized } from "./FlinkErrors";
|
|
15
|
-
import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps } from "./FlinkHttpHandler";
|
|
19
|
+
import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps, ValidationMode } from "./FlinkHttpHandler";
|
|
16
20
|
import { FlinkJobFile } from "./FlinkJob";
|
|
17
21
|
import { log } from "./FlinkLog";
|
|
18
22
|
import { FlinkPlugin } from "./FlinkPlugin";
|
|
19
23
|
import { FlinkRepo } from "./FlinkRepo";
|
|
20
24
|
import { FlinkResponse } from "./FlinkResponse";
|
|
25
|
+
import { StreamWriterFactory } from "./handlers/StreamWriterFactory";
|
|
21
26
|
import generateMockData from "./mock-data-generator";
|
|
22
27
|
import { formatValidationErrors, getPathParams, isError } from "./utils";
|
|
23
28
|
|
|
@@ -63,6 +68,18 @@ export const autoRegisteredRepos: {
|
|
|
63
68
|
*/
|
|
64
69
|
export const autoRegisteredJobs: FlinkJobFile[] = [];
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* This will be populated at compile time when the apps tools
|
|
73
|
+
* are picked up by TypeScript compiler
|
|
74
|
+
*/
|
|
75
|
+
export const autoRegisteredTools: FlinkToolFile[] = [];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* This will be populated at compile time when the apps agents
|
|
79
|
+
* are picked up by TypeScript compiler
|
|
80
|
+
*/
|
|
81
|
+
export const autoRegisteredAgents: FlinkAgentFile[] = [];
|
|
82
|
+
|
|
66
83
|
export interface FlinkOptions {
|
|
67
84
|
/**
|
|
68
85
|
* Name of application, will only show in logs and in HTTP header.
|
|
@@ -175,6 +192,15 @@ export interface FlinkOptions {
|
|
|
175
192
|
// autoAssignCollection?: string;
|
|
176
193
|
};
|
|
177
194
|
|
|
195
|
+
/**
|
|
196
|
+
* AI configuration for agents and tools
|
|
197
|
+
* Register LLM adapters with custom IDs (e.g., "anthropic", "openai", "anthropic-eu", etc.)
|
|
198
|
+
* This allows multiple adapters of the same type with different configurations
|
|
199
|
+
*/
|
|
200
|
+
ai?: {
|
|
201
|
+
llms?: { [id: string]: LLMAdapter };
|
|
202
|
+
};
|
|
203
|
+
|
|
178
204
|
/**
|
|
179
205
|
* If true, the HTTP server will be disabled.
|
|
180
206
|
* Only useful when starting a Flink app for testing purposes.
|
|
@@ -294,6 +320,10 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
294
320
|
|
|
295
321
|
private repos: { [x: string]: FlinkRepo<C, any> } = {};
|
|
296
322
|
|
|
323
|
+
private llmAdapters: Map<string, LLMAdapter> = new Map();
|
|
324
|
+
private tools: { [x: string]: ToolExecutor<C> } = {};
|
|
325
|
+
private agents: { [x: string]: any } = {}; // FlinkAgent<C> instances
|
|
326
|
+
|
|
297
327
|
/**
|
|
298
328
|
* Internal cache used to track registered handlers and potentially any overlapping routes
|
|
299
329
|
*/
|
|
@@ -314,14 +344,20 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
314
344
|
this.rawContentTypes = Array.isArray(opts.rawContentTypes)
|
|
315
345
|
? opts.rawContentTypes
|
|
316
346
|
: typeof opts.rawContentTypes === "string"
|
|
317
|
-
|
|
318
|
-
|
|
347
|
+
? [opts.rawContentTypes]
|
|
348
|
+
: undefined;
|
|
319
349
|
this.auth = opts.auth;
|
|
320
350
|
this.jsonOptions = opts.jsonOptions || { limit: "1mb" };
|
|
321
351
|
this.schedulingOptions = opts.scheduling;
|
|
322
352
|
this.disableHttpServer = !!opts.disableHttpServer;
|
|
323
353
|
this.accessLog = { enabled: true, format: "dev", ...opts.accessLog };
|
|
324
354
|
this.onError = opts.onError;
|
|
355
|
+
|
|
356
|
+
// Register LLM adapters if configured
|
|
357
|
+
if (opts.ai?.llms) {
|
|
358
|
+
// Convert plain object to Map for internal use
|
|
359
|
+
this.llmAdapters = new Map(Object.entries(opts.ai.llms));
|
|
360
|
+
}
|
|
325
361
|
}
|
|
326
362
|
|
|
327
363
|
get ctx() {
|
|
@@ -342,6 +378,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
342
378
|
log.bgColorLog("cyan", `Init db took ${offsetTime - startTime} ms`);
|
|
343
379
|
}
|
|
344
380
|
|
|
381
|
+
// Build initial context (without agents - they'll be added later)
|
|
345
382
|
await this.buildContext();
|
|
346
383
|
|
|
347
384
|
if (this.debug) {
|
|
@@ -349,6 +386,30 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
349
386
|
offsetTime = Date.now();
|
|
350
387
|
}
|
|
351
388
|
|
|
389
|
+
// Register tools (needs context for ToolExecutor)
|
|
390
|
+
await this.registerAutoRegisterableTools();
|
|
391
|
+
|
|
392
|
+
if (this.debug) {
|
|
393
|
+
log.bgColorLog("cyan", `Register tools took ${Date.now() - offsetTime} ms`);
|
|
394
|
+
offsetTime = Date.now();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Register agents (creates agent instances)
|
|
398
|
+
await this.registerAutoRegisterableAgents();
|
|
399
|
+
|
|
400
|
+
if (this.debug) {
|
|
401
|
+
log.bgColorLog("cyan", `Register agents took ${Date.now() - offsetTime} ms`);
|
|
402
|
+
offsetTime = Date.now();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Initialize agents now that context and tools are ready
|
|
406
|
+
await this.initializeAgents();
|
|
407
|
+
|
|
408
|
+
if (this.debug) {
|
|
409
|
+
log.bgColorLog("cyan", `Initialize agents took ${Date.now() - offsetTime} ms`);
|
|
410
|
+
offsetTime = Date.now();
|
|
411
|
+
}
|
|
412
|
+
|
|
352
413
|
if (this.isSchedulingEnabled) {
|
|
353
414
|
this.scheduler = new ToadScheduler();
|
|
354
415
|
} else {
|
|
@@ -519,7 +580,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
519
580
|
this.handlers.push(handlerConfig);
|
|
520
581
|
|
|
521
582
|
const { routeProps, schema = {} } = handlerConfig;
|
|
522
|
-
const { method } = routeProps;
|
|
583
|
+
const { method, streamFormat } = routeProps;
|
|
523
584
|
|
|
524
585
|
if (!method) {
|
|
525
586
|
log.error(`Route ${routeProps.path} is missing http method`);
|
|
@@ -535,11 +596,16 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
535
596
|
let validateReq: ValidateFunction<any> | undefined;
|
|
536
597
|
let validateRes: ValidateFunction<any> | undefined;
|
|
537
598
|
|
|
538
|
-
if
|
|
599
|
+
// Determine validation mode (default to Validate if not specified)
|
|
600
|
+
const validationMode = routeProps.validation || ValidationMode.Validate;
|
|
601
|
+
|
|
602
|
+
// Compile request schema if validation mode requires it
|
|
603
|
+
if (schema.reqSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateResponse) {
|
|
539
604
|
validateReq = ajv.compile(schema.reqSchema);
|
|
540
605
|
}
|
|
541
606
|
|
|
542
|
-
|
|
607
|
+
// Skip response validation for streaming handlers (responses are stream chunks, not final JSON)
|
|
608
|
+
if (!streamFormat && schema.resSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateRequest) {
|
|
543
609
|
validateRes = ajv.compile(schema.resSchema);
|
|
544
610
|
}
|
|
545
611
|
|
|
@@ -568,7 +634,8 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
568
634
|
}
|
|
569
635
|
}
|
|
570
636
|
|
|
571
|
-
|
|
637
|
+
// Skip mock API for streaming handlers
|
|
638
|
+
if (routeProps.mockApi && schema.resSchema && !streamFormat) {
|
|
572
639
|
log.warn(`Mock response for ${req.method.toUpperCase()} ${req.path}`);
|
|
573
640
|
|
|
574
641
|
const data = generateMockData(schema.resSchema);
|
|
@@ -580,6 +647,27 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
580
647
|
return;
|
|
581
648
|
}
|
|
582
649
|
|
|
650
|
+
// Normalize query parameters to predictable string or string[] types
|
|
651
|
+
// Express query parser can produce numbers, booleans, objects, etc.
|
|
652
|
+
// We normalize everything to strings or string arrays for consistency
|
|
653
|
+
if (req.query && typeof req.query === "object") {
|
|
654
|
+
const normalizedQuery: Record<string, string | string[]> = {};
|
|
655
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
656
|
+
if (Array.isArray(value)) {
|
|
657
|
+
// Handle array values (e.g., ?tag=a&tag=b)
|
|
658
|
+
normalizedQuery[key] = value.map((v) => String(v));
|
|
659
|
+
} else if (value !== undefined && value !== null) {
|
|
660
|
+
// Convert single values to strings
|
|
661
|
+
normalizedQuery[key] = String(value);
|
|
662
|
+
}
|
|
663
|
+
// Skip undefined/null values - they won't appear in the normalized query
|
|
664
|
+
}
|
|
665
|
+
req.query = normalizedQuery;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Create stream writer if streaming handler
|
|
669
|
+
const stream = streamFormat ? StreamWriterFactory.create(res, streamFormat) : undefined;
|
|
670
|
+
|
|
583
671
|
let handlerRes: FlinkResponse<any>;
|
|
584
672
|
|
|
585
673
|
try {
|
|
@@ -588,8 +676,19 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
588
676
|
req: req as FlinkRequest,
|
|
589
677
|
ctx: this.ctx,
|
|
590
678
|
origin: routeProps.origin,
|
|
679
|
+
stream,
|
|
591
680
|
});
|
|
592
681
|
} catch (err: any) {
|
|
682
|
+
// Handle errors for streaming handlers
|
|
683
|
+
if (streamFormat && stream) {
|
|
684
|
+
log.error(`Streaming handler error on ${req.method.toUpperCase()} ${req.path}: ${err.message}`, {
|
|
685
|
+
error: err,
|
|
686
|
+
path: req.path,
|
|
687
|
+
method: req.method,
|
|
688
|
+
});
|
|
689
|
+
stream.error(err);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
593
692
|
let errorResponse: FlinkResponse<FlinkError>;
|
|
594
693
|
|
|
595
694
|
// duck typing to check if it is a FlinkError
|
|
@@ -633,6 +732,16 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
633
732
|
return res.status(errorResponse.status || 500).json(errorResponse);
|
|
634
733
|
}
|
|
635
734
|
|
|
735
|
+
// Skip response handling for streaming handlers (stream controls response lifecycle)
|
|
736
|
+
if (streamFormat) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Ensure handlerRes is defined for non-streaming handlers
|
|
741
|
+
if (!handlerRes) {
|
|
742
|
+
return res.status(204).send();
|
|
743
|
+
}
|
|
744
|
+
|
|
636
745
|
if (validateRes && !isError(handlerRes)) {
|
|
637
746
|
const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
|
|
638
747
|
|
|
@@ -661,7 +770,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
661
770
|
return process.exit(1); // TODO: Do we need to exit?
|
|
662
771
|
} else {
|
|
663
772
|
this.handlerRouteCache.set(methodAndRoute, JSON.stringify(routeProps));
|
|
664
|
-
log.info(`Registered route ${methodAndRoute}`);
|
|
773
|
+
log.info(`Registered ${streamFormat ? 'streaming ' : ''}route ${methodAndRoute}${streamFormat ? ` (${streamFormat})` : ''}`);
|
|
665
774
|
}
|
|
666
775
|
}
|
|
667
776
|
}
|
|
@@ -813,6 +922,151 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
813
922
|
// repoInstance.ctx = this.ctx;
|
|
814
923
|
}
|
|
815
924
|
|
|
925
|
+
private async registerAutoRegisterableTools() {
|
|
926
|
+
const { ToolExecutor } = require("./ai/ToolExecutor");
|
|
927
|
+
const { getRepoInstanceName } = require("./utils");
|
|
928
|
+
|
|
929
|
+
for (const toolFile of autoRegisteredTools) {
|
|
930
|
+
if (!toolFile.Tool) {
|
|
931
|
+
log.error(`Missing FlinkToolProps export in tool ${toolFile.__file}`);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!toolFile.default) {
|
|
936
|
+
log.error(`Missing exported tool function in tool ${toolFile.__file}`);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const toolId = toolFile.Tool.id;
|
|
941
|
+
if (!toolId) {
|
|
942
|
+
log.error(`Tool ${toolFile.__file} missing 'id' property`);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const toolInstanceName = getRepoInstanceName(toolId);
|
|
947
|
+
|
|
948
|
+
const toolExecutor = new ToolExecutor(toolFile.Tool, toolFile.default, this.ctx);
|
|
949
|
+
this.tools[toolInstanceName] = toolExecutor;
|
|
950
|
+
|
|
951
|
+
log.info(`Registered tool ${toolInstanceName} (${toolId})`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private async registerAutoRegisterableAgents() {
|
|
956
|
+
const { getRepoInstanceName, toKebabCase } = require("./utils");
|
|
957
|
+
const { SubAgentExecutor } = require("./ai/SubAgentExecutor");
|
|
958
|
+
|
|
959
|
+
for (const agentFile of autoRegisteredAgents) {
|
|
960
|
+
// agentFile now exports a class, not a config object
|
|
961
|
+
const AgentClass = agentFile.default;
|
|
962
|
+
|
|
963
|
+
if (!AgentClass) {
|
|
964
|
+
log.error(`Missing default export in agent ${agentFile.__file}`);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Instantiate agent (similar to repo instantiation)
|
|
969
|
+
const agentInstance = new AgentClass();
|
|
970
|
+
|
|
971
|
+
// Derive instance name from class name (camelCase)
|
|
972
|
+
const agentInstanceName = getRepoInstanceName(AgentClass.name);
|
|
973
|
+
|
|
974
|
+
// Get agent ID (kebab-case) - either explicit or derived
|
|
975
|
+
const agentId = agentInstance.id || toKebabCase(AgentClass.name);
|
|
976
|
+
|
|
977
|
+
// Check for duplicate instance name
|
|
978
|
+
if (this.agents[agentInstanceName]) {
|
|
979
|
+
const existingAgent = this.agents[agentInstanceName];
|
|
980
|
+
throw new Error(
|
|
981
|
+
`Duplicate agent instance name: "${agentInstanceName}". ` +
|
|
982
|
+
`Agent class "${AgentClass.name}" conflicts with existing agent "${existingAgent.constructor.name}". ` +
|
|
983
|
+
`Instance names are derived by lowercasing the first letter of the class name. ` +
|
|
984
|
+
`Rename one of the classes or use a unique explicit 'id' property.`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Check for duplicate agent ID
|
|
989
|
+
const existingAgentWithSameId = Object.values(this.agents).find(
|
|
990
|
+
(agent: any) => {
|
|
991
|
+
const existingId = agent.id || toKebabCase(agent.constructor.name);
|
|
992
|
+
return existingId === agentId;
|
|
993
|
+
}
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
if (existingAgentWithSameId) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Duplicate agent ID: "${agentId}". ` +
|
|
999
|
+
`Agent class "${AgentClass.name}" conflicts with existing agent "${existingAgentWithSameId.constructor.name}". ` +
|
|
1000
|
+
`Agent IDs are derived from class names using kebab-case (e.g., CarAgent → car-agent). ` +
|
|
1001
|
+
`Use an explicit 'id' property to resolve this conflict:\n` +
|
|
1002
|
+
` id = "my-unique-id";`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Validate tools exist
|
|
1007
|
+
for (const toolRef of agentInstance.tools) {
|
|
1008
|
+
// Handle both string IDs and tool file references
|
|
1009
|
+
const toolId = typeof toolRef === "string"
|
|
1010
|
+
? toolRef
|
|
1011
|
+
: toolRef.Tool.id; // Extract ID from FlinkToolFile
|
|
1012
|
+
|
|
1013
|
+
const tool = this.tools[toolId];
|
|
1014
|
+
|
|
1015
|
+
if (!tool) {
|
|
1016
|
+
log.error(`Agent ${AgentClass.name} references tool ${toolId} which is not registered`);
|
|
1017
|
+
throw new Error(`Invalid tool reference in agent ${AgentClass.name}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Validate and register sub-agents
|
|
1022
|
+
if (agentInstance.agents && agentInstance.agents.length > 0) {
|
|
1023
|
+
for (const agentRef of agentInstance.agents) {
|
|
1024
|
+
// Get instance name directly from class reference or string
|
|
1025
|
+
const subAgentInstanceName = typeof agentRef === "string" ? agentRef : getRepoInstanceName(agentRef.name);
|
|
1026
|
+
|
|
1027
|
+
// Validate that sub-agent will exist (will be registered in this loop)
|
|
1028
|
+
// For now, just log - actual validation happens at runtime
|
|
1029
|
+
log.debug(`Agent ${AgentClass.name} references sub-agent ${subAgentInstanceName}`);
|
|
1030
|
+
|
|
1031
|
+
// Create a SubAgentExecutor as a special tool
|
|
1032
|
+
const subAgentToolName = `ask_${subAgentInstanceName}`;
|
|
1033
|
+
const subAgentExecutor = new SubAgentExecutor(subAgentInstanceName, this.ctx);
|
|
1034
|
+
|
|
1035
|
+
// Register as a tool so it appears in the agent's tool list
|
|
1036
|
+
this.tools[subAgentToolName] = subAgentExecutor as any;
|
|
1037
|
+
log.debug(`Created sub-agent tool ${subAgentToolName} for ${subAgentInstanceName}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Register agent (duplicate checks already performed above)
|
|
1042
|
+
this.agents[agentInstanceName] = agentInstance;
|
|
1043
|
+
log.info(`Registered agent ${agentInstanceName} (${AgentClass.name}) with ID: ${agentId}`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Second pass: validate all sub-agent references
|
|
1047
|
+
for (const agentFile of autoRegisteredAgents) {
|
|
1048
|
+
const AgentClass = agentFile.default;
|
|
1049
|
+
|
|
1050
|
+
if (!AgentClass) {
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const agentInstance = new AgentClass();
|
|
1055
|
+
|
|
1056
|
+
if (agentInstance.agents && agentInstance.agents.length > 0) {
|
|
1057
|
+
for (const agentRef of agentInstance.agents) {
|
|
1058
|
+
// Get instance name directly from class reference or string
|
|
1059
|
+
const subAgentInstanceName = typeof agentRef === "string" ? agentRef : getRepoInstanceName(agentRef.name);
|
|
1060
|
+
|
|
1061
|
+
if (!this.agents[subAgentInstanceName]) {
|
|
1062
|
+
log.error(`Agent ${AgentClass.name} references sub-agent ${subAgentInstanceName} which is not registered`);
|
|
1063
|
+
throw new Error(`Invalid sub-agent reference in agent ${AgentClass.name}: ${subAgentInstanceName}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
816
1070
|
/**
|
|
817
1071
|
* Constructs the app context. Will inject context in all components
|
|
818
1072
|
* except for handlers which are handled in later stage.
|
|
@@ -842,6 +1096,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
842
1096
|
repos: this.repos,
|
|
843
1097
|
plugins: pluginCtx,
|
|
844
1098
|
auth: this.auth,
|
|
1099
|
+
agents: this.agents,
|
|
845
1100
|
} as C;
|
|
846
1101
|
|
|
847
1102
|
for (const repo of Object.values(this.repos)) {
|
|
@@ -849,6 +1104,18 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
849
1104
|
}
|
|
850
1105
|
}
|
|
851
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* Initialize agents after they've been registered and context is ready
|
|
1109
|
+
* Must be called after registerAutoRegisterableAgents()
|
|
1110
|
+
*/
|
|
1111
|
+
private async initializeAgents() {
|
|
1112
|
+
// Inject context and initialize agents
|
|
1113
|
+
for (const agent of Object.values(this.agents)) {
|
|
1114
|
+
agent.ctx = this.ctx;
|
|
1115
|
+
agent.__init(this.llmAdapters, this.tools);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
852
1119
|
/**
|
|
853
1120
|
* Connects to database.
|
|
854
1121
|
*/
|
package/src/FlinkContext.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
|
|
2
2
|
import { FlinkRepo } from "./FlinkRepo";
|
|
3
|
+
import { FlinkAgent } from "./ai/FlinkAgent";
|
|
3
4
|
|
|
4
5
|
export interface FlinkContext<P = any> {
|
|
5
6
|
repos: {
|
|
@@ -12,4 +13,25 @@ export interface FlinkContext<P = any> {
|
|
|
12
13
|
* Type of authentication, if any.
|
|
13
14
|
*/
|
|
14
15
|
auth?: FlinkAuthPlugin;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* AI namespace containing agents
|
|
19
|
+
*
|
|
20
|
+
* Define agents directly in your context interface:
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* interface AppCtx extends FlinkContext<PluginCtx> {
|
|
24
|
+
* auth: JwtAuthPlugin;
|
|
25
|
+
* repos: {
|
|
26
|
+
* carRepo: CarRepo;
|
|
27
|
+
* };
|
|
28
|
+
* agents: {
|
|
29
|
+
* carAgent: CarAgent;
|
|
30
|
+
* userAgent: UserAgent;
|
|
31
|
+
* };
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
agents?: {
|
|
35
|
+
[x: string]: FlinkAgent<any>;
|
|
36
|
+
};
|
|
15
37
|
}
|
package/src/FlinkHttpHandler.ts
CHANGED
|
@@ -12,24 +12,104 @@ export enum HttpMethod {
|
|
|
12
12
|
patch = "patch",
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Validation mode for handler request and response schemas.
|
|
17
|
+
*
|
|
18
|
+
* Controls whether request and/or response data is validated against JSON schemas.
|
|
19
|
+
*
|
|
20
|
+
* **Security Note:** Skipping validation can introduce security risks. Only use
|
|
21
|
+
* SkipValidation or ValidateResponse when you have implemented custom validation
|
|
22
|
+
* or the endpoint is internal/trusted.
|
|
23
|
+
*
|
|
24
|
+
* - Validate: Validate both request and response (default behavior)
|
|
25
|
+
* - SkipValidation: Skip both request and response validation
|
|
26
|
+
* - ValidateRequest: Validate only request, skip response validation
|
|
27
|
+
* - ValidateResponse: Validate only response, skip request validation
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // Skip validation for webhook with custom signature verification
|
|
32
|
+
* export const Route: RouteProps = {
|
|
33
|
+
* path: "/webhook",
|
|
34
|
+
* validation: ValidationMode.SkipValidation
|
|
35
|
+
* };
|
|
36
|
+
*
|
|
37
|
+
* // Validate request but allow flexible response during development
|
|
38
|
+
* export const Route: RouteProps = {
|
|
39
|
+
* path: "/api/data",
|
|
40
|
+
* validation: ValidationMode.ValidateRequest
|
|
41
|
+
* };
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export enum ValidationMode {
|
|
45
|
+
Validate = "Validate",
|
|
46
|
+
SkipValidation = "SkipValidation",
|
|
47
|
+
ValidateRequest = "ValidateRequest",
|
|
48
|
+
ValidateResponse = "ValidateResponse",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Params = Record<string, string>;
|
|
16
52
|
|
|
17
53
|
/**
|
|
18
54
|
* Query type for request query parameters.
|
|
19
|
-
* Does currently not allow nested objects, although
|
|
20
|
-
* underlying express Request does allow it.
|
|
21
55
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
56
|
+
* All query parameter values are normalized to strings or string arrays:
|
|
57
|
+
* - Single values: string (e.g., ?name=John becomes { name: "John" })
|
|
58
|
+
* - Multiple values: string[] (e.g., ?tag=a&tag=b becomes { tag: ["a", "b"] })
|
|
59
|
+
*
|
|
60
|
+
* Does not allow nested objects, although underlying Express Request does allow it.
|
|
24
61
|
*/
|
|
25
|
-
type Query =
|
|
26
|
-
[x: string]: string | string[] | undefined;
|
|
27
|
-
};
|
|
62
|
+
type Query = Record<string, string | string[]>;
|
|
28
63
|
|
|
29
64
|
/**
|
|
30
|
-
*
|
|
65
|
+
* Stream format for streaming handlers.
|
|
66
|
+
* - sse: Server-Sent Events (text/event-stream)
|
|
67
|
+
* - ndjson: Newline-Delimited JSON (application/x-ndjson)
|
|
31
68
|
*/
|
|
32
|
-
export type
|
|
69
|
+
export type StreamFormat = "sse" | "ndjson";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stream writer interface for SSE/NDJSON streaming.
|
|
73
|
+
*
|
|
74
|
+
* Provides methods to write data chunks, handle errors, and manage the stream lifecycle.
|
|
75
|
+
* Only available in handlers where streamFormat is specified in RouteProps.
|
|
76
|
+
*/
|
|
77
|
+
export interface StreamWriter<T = any> {
|
|
78
|
+
/**
|
|
79
|
+
* Write data to the stream.
|
|
80
|
+
* Data is automatically JSON-stringified and formatted according to streamFormat.
|
|
81
|
+
*/
|
|
82
|
+
write(data: T): void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Send error to client and close the stream.
|
|
86
|
+
*/
|
|
87
|
+
error(error: Error | string): void;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Close the stream gracefully.
|
|
91
|
+
*/
|
|
92
|
+
end(): void;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if stream is still open (client connected).
|
|
96
|
+
* Returns false if client has disconnected.
|
|
97
|
+
*/
|
|
98
|
+
isOpen(): boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Flink request extends express Request but adds reqId, user object, and userPermissions.
|
|
103
|
+
*
|
|
104
|
+
* userPermissions is populated by auth plugins during authentication and contains
|
|
105
|
+
* the resolved permissions array based on the plugin's configuration (roles, dynamic
|
|
106
|
+
* roles, custom permissions, etc.)
|
|
107
|
+
*/
|
|
108
|
+
export type FlinkRequest<T = any, P = Params, Q = Query> = Request<P, any, T, Q> & {
|
|
109
|
+
reqId: string;
|
|
110
|
+
user?: any;
|
|
111
|
+
userPermissions?: string[]; // Resolved permissions from auth plugin
|
|
112
|
+
};
|
|
33
113
|
|
|
34
114
|
/**
|
|
35
115
|
* Route props to control routing.
|
|
@@ -64,6 +144,27 @@ export interface RouteProps {
|
|
|
64
144
|
|
|
65
145
|
/**
|
|
66
146
|
* Set permissions needed to access route if route requires authentication.
|
|
147
|
+
*
|
|
148
|
+
* When an array is provided, the user must have **ALL** permissions in the array (AND logic).
|
|
149
|
+
* To require any one of multiple permissions (OR logic), implement custom permission
|
|
150
|
+
* checking in the handler using the `user.permissions` array.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* // Single permission
|
|
155
|
+
* permissions: "car:create"
|
|
156
|
+
*
|
|
157
|
+
* // Multiple permissions (user must have ALL)
|
|
158
|
+
* permissions: ["car:create", "car:premium"]
|
|
159
|
+
*
|
|
160
|
+
* // OR logic requires custom implementation
|
|
161
|
+
* const handler = async ({ ctx, user }) => {
|
|
162
|
+
* if (!user.permissions.includes("car:admin") && !user.permissions.includes("car:moderator")) {
|
|
163
|
+
* throw forbidden("Need admin or moderator permission");
|
|
164
|
+
* }
|
|
165
|
+
* // ... handler logic
|
|
166
|
+
* };
|
|
167
|
+
* ```
|
|
67
168
|
*/
|
|
68
169
|
permissions?: string | string[];
|
|
69
170
|
|
|
@@ -91,16 +192,69 @@ export interface RouteProps {
|
|
|
91
192
|
* to avoid conflicts you can set a negative order.
|
|
92
193
|
*/
|
|
93
194
|
order?: number;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validation mode for request and response schemas.
|
|
198
|
+
*
|
|
199
|
+
* Controls schema validation behavior for this handler. Use with caution as
|
|
200
|
+
* skipping validation can introduce security vulnerabilities.
|
|
201
|
+
*
|
|
202
|
+
* **Options:**
|
|
203
|
+
* - Validate: Validate both request and response (default)
|
|
204
|
+
* - SkipValidation: Skip both request and response validation
|
|
205
|
+
* - ValidateRequest: Validate only request, skip response validation
|
|
206
|
+
* - ValidateResponse: Validate only response, skip request validation
|
|
207
|
+
*
|
|
208
|
+
* **When to skip validation:**
|
|
209
|
+
* - Webhook handlers with custom signature verification
|
|
210
|
+
* - Performance-critical internal endpoints
|
|
211
|
+
* - Handlers using alternative validation methods (e.g., Zod, Joi)
|
|
212
|
+
*
|
|
213
|
+
* @default ValidationMode.Validate
|
|
214
|
+
*/
|
|
215
|
+
validation?: ValidationMode;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Stream format for streaming handlers (SSE or NDJSON).
|
|
219
|
+
*
|
|
220
|
+
* When specified, the handler becomes a streaming handler and receives a `stream`
|
|
221
|
+
* parameter for writing data chunks. Response validation is automatically skipped
|
|
222
|
+
* for streaming handlers (chunks are progressive, not a final JSON response).
|
|
223
|
+
*
|
|
224
|
+
* **Formats:**
|
|
225
|
+
* - sse: Server-Sent Events (text/event-stream) - ideal for browser EventSource API
|
|
226
|
+
* - ndjson: Newline-Delimited JSON (application/x-ndjson) - ideal for LLM text streaming
|
|
227
|
+
*
|
|
228
|
+
* **Example:**
|
|
229
|
+
* ```typescript
|
|
230
|
+
* export const Route: RouteProps = {
|
|
231
|
+
* path: "/ai/stream",
|
|
232
|
+
* streamFormat: "sse"
|
|
233
|
+
* };
|
|
234
|
+
*
|
|
235
|
+
* const handler: GetHandler<{}, void> = async ({ ctx, stream }) => {
|
|
236
|
+
* if (!stream) throw new Error("Stream not available");
|
|
237
|
+
* stream.write({ message: "Hello" });
|
|
238
|
+
* stream.end();
|
|
239
|
+
* };
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
streamFormat?: StreamFormat;
|
|
94
243
|
}
|
|
95
244
|
|
|
96
245
|
/**
|
|
97
246
|
* Http handler function that handlers implements in order to
|
|
98
247
|
* handle HTTP requests and return a JSON response.
|
|
248
|
+
*
|
|
249
|
+
* For streaming handlers (when streamFormat is specified in RouteProps),
|
|
250
|
+
* the stream parameter is available. Streaming handlers should still return
|
|
251
|
+
* a FlinkResponse (can be empty), but it will be ignored by the framework.
|
|
99
252
|
*/
|
|
100
253
|
export type Handler<Ctx extends FlinkContext, ReqSchema = any, ResSchema = any, P extends Params = Params, Q extends Query = Query> = (props: {
|
|
101
254
|
req: FlinkRequest<ReqSchema, P, Q>;
|
|
102
255
|
ctx: Ctx;
|
|
103
256
|
origin?: string;
|
|
257
|
+
stream?: StreamWriter;
|
|
104
258
|
}) => Promise<FlinkResponse<ResSchema | FlinkError>>;
|
|
105
259
|
|
|
106
260
|
/**
|