@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/cli/build.ts +8 -1
  3. package/cli/run.ts +8 -1
  4. package/dist/cli/build.js +8 -1
  5. package/dist/cli/run.js +8 -1
  6. package/dist/src/FlinkApp.d.ts +33 -0
  7. package/dist/src/FlinkApp.js +279 -35
  8. package/dist/src/FlinkContext.d.ts +21 -0
  9. package/dist/src/FlinkHttpHandler.d.ts +152 -9
  10. package/dist/src/FlinkHttpHandler.js +37 -1
  11. package/dist/src/TypeScriptCompiler.d.ts +42 -0
  12. package/dist/src/TypeScriptCompiler.js +346 -4
  13. package/dist/src/TypeScriptUtils.js +4 -0
  14. package/dist/src/ai/AgentRunner.d.ts +39 -0
  15. package/dist/src/ai/AgentRunner.js +625 -0
  16. package/dist/src/ai/FlinkAgent.d.ts +446 -0
  17. package/dist/src/ai/FlinkAgent.js +633 -0
  18. package/dist/src/ai/FlinkTool.d.ts +37 -0
  19. package/dist/src/ai/FlinkTool.js +2 -0
  20. package/dist/src/ai/LLMAdapter.d.ts +119 -0
  21. package/dist/src/ai/LLMAdapter.js +2 -0
  22. package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
  23. package/dist/src/ai/SubAgentExecutor.js +220 -0
  24. package/dist/src/ai/ToolExecutor.d.ts +35 -0
  25. package/dist/src/ai/ToolExecutor.js +237 -0
  26. package/dist/src/ai/index.d.ts +5 -0
  27. package/dist/src/ai/index.js +21 -0
  28. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  29. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  30. package/dist/src/index.d.ts +4 -0
  31. package/dist/src/index.js +4 -0
  32. package/dist/src/utils.d.ts +30 -0
  33. package/dist/src/utils.js +52 -0
  34. package/package.json +16 -2
  35. package/readme.md +425 -0
  36. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  37. package/spec/AgentRunner.spec.ts +527 -0
  38. package/spec/ConversationHooks.spec.ts +290 -0
  39. package/spec/FlinkAgent.spec.ts +310 -0
  40. package/spec/FlinkApp.onError.spec.ts +1 -2
  41. package/spec/FlinkApp.query.spec.ts +107 -0
  42. package/spec/FlinkApp.validationMode.spec.ts +155 -0
  43. package/spec/StreamingIntegration.spec.ts +138 -0
  44. package/spec/SubAgentSupport.spec.ts +941 -0
  45. package/spec/ToolExecutor.spec.ts +360 -0
  46. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
  47. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
  48. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
  49. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
  50. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
  51. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
  52. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
  53. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
  54. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
  55. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  56. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  57. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
  58. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
  59. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
  60. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
  61. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
  62. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
  63. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
  64. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
  65. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
  66. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  67. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  68. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  69. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  70. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  71. package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
  72. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  73. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  74. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  75. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  76. package/spec/mock-project/dist/src/FlinkLog.js +26 -0
  77. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  78. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  79. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  80. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  81. package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
  82. package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
  83. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  84. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  85. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
  86. package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
  87. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  88. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  89. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  90. package/spec/mock-project/dist/src/index.js +17 -69
  91. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  92. package/spec/mock-project/dist/src/utils.js +290 -0
  93. package/spec/mock-project/tsconfig.json +6 -1
  94. package/spec/testHelpers.ts +49 -0
  95. package/spec/utils.caseConversion.spec.ts +80 -0
  96. package/spec/utils.spec.ts +13 -13
  97. package/src/FlinkApp.ts +275 -8
  98. package/src/FlinkContext.ts +22 -0
  99. package/src/FlinkHttpHandler.ts +164 -10
  100. package/src/TypeScriptCompiler.ts +398 -7
  101. package/src/TypeScriptUtils.ts +5 -0
  102. package/src/ai/AgentRunner.ts +549 -0
  103. package/src/ai/FlinkAgent.ts +770 -0
  104. package/src/ai/FlinkTool.ts +40 -0
  105. package/src/ai/LLMAdapter.ts +96 -0
  106. package/src/ai/SubAgentExecutor.ts +199 -0
  107. package/src/ai/ToolExecutor.ts +193 -0
  108. package/src/ai/index.ts +5 -0
  109. package/src/handlers/StreamWriterFactory.ts +84 -0
  110. package/src/index.ts +4 -0
  111. package/src/utils.ts +52 -0
  112. 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
- ? [opts.rawContentTypes]
318
- : undefined;
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 (schema.reqSchema) {
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
- if (schema.resSchema) {
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
- if (routeProps.mockApi && schema.resSchema) {
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
  */
@@ -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
  }
@@ -12,24 +12,104 @@ export enum HttpMethod {
12
12
  patch = "patch",
13
13
  }
14
14
 
15
- type Params = Request["params"];
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
- * Uses index signature to allow both Record types and interface types
23
- * to be assignable to Query without requiring explicit index signatures.
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
- * Flink request extends express Request but adds reqId and user object.
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 FlinkRequest<T = any, P = Params, Q = Query> = Request<P, any, T, Q> & { reqId: string; user?: any };
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
  /**