@flink-app/flink 2.0.0-alpha.61 → 2.0.0-alpha.63

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/src/FlinkApp.ts CHANGED
@@ -790,20 +790,32 @@ export class FlinkApp<C extends FlinkContext> {
790
790
  }
791
791
 
792
792
  if (validateRes && !isError(handlerRes)) {
793
- const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
794
-
795
- if (!valid) {
796
- const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
797
- log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
798
-
799
- return res.status(500).json({
800
- status: 500,
801
- error: {
802
- id: v4(),
803
- title: "Bad response",
804
- detail: formattedErrors,
805
- },
806
- });
793
+ if (handlerRes.data === undefined) {
794
+ if (handlerRes.status !== 204) {
795
+ const detail =
796
+ "Response schema is defined but handler returned no data";
797
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response - ${detail}`);
798
+ return res.status(500).json({
799
+ status: 500,
800
+ error: { id: v4(), title: "Bad response", detail },
801
+ });
802
+ }
803
+ } else {
804
+ const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
805
+
806
+ if (!valid) {
807
+ const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
808
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
809
+
810
+ return res.status(500).json({
811
+ status: 500,
812
+ error: {
813
+ id: v4(),
814
+ title: "Bad response",
815
+ detail: formattedErrors,
816
+ },
817
+ });
818
+ }
807
819
  }
808
820
  }
809
821
 
@@ -1198,18 +1210,30 @@ export class FlinkApp<C extends FlinkContext> {
1198
1210
 
1199
1211
  this.scheduler.addSimpleIntervalJob(job);
1200
1212
  } else if (jobProps.afterDelay !== undefined) {
1201
- const job = new SimpleIntervalJob(
1202
- {
1203
- milliseconds: ms(jobProps.afterDelay),
1204
- runImmediately: false,
1205
- },
1206
- task,
1207
- {
1208
- id: jobProps.id,
1209
- preventOverrun: jobProps.singleton,
1210
- }
1211
- );
1212
- this.scheduler.addSimpleIntervalJob(job);
1213
+ const delayMs = ms(jobProps.afterDelay);
1214
+ if (delayMs === 0) {
1215
+ setImmediate(async () => {
1216
+ try {
1217
+ await jobFn({ ctx: this.ctx });
1218
+ } catch (err) {
1219
+ log.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
1220
+ console.error(err);
1221
+ }
1222
+ });
1223
+ } else {
1224
+ const job = new SimpleIntervalJob(
1225
+ {
1226
+ milliseconds: delayMs,
1227
+ runImmediately: false,
1228
+ },
1229
+ task,
1230
+ {
1231
+ id: jobProps.id,
1232
+ preventOverrun: jobProps.singleton,
1233
+ }
1234
+ );
1235
+ this.scheduler.addSimpleIntervalJob(job);
1236
+ }
1213
1237
  } else {
1214
1238
  log.error(`Cannot register job ${jobProps.id} - no cron, interval or once set in ${__file}`);
1215
1239
  continue;
@@ -1270,11 +1294,15 @@ export class FlinkApp<C extends FlinkContext> {
1270
1294
  log.warn(`Tool ${toolFile.__file} references outputSchema "${metadata.outputSchemaName}" but not found in schema universe`);
1271
1295
  }
1272
1296
 
1297
+ // Pass full schema universe so AJV can resolve $ref across schemas
1298
+ const allSchemas = schemaManifest.version === "2.0" ? schemaManifest.schemas : schemaManifest.definitions;
1299
+
1273
1300
  const toolExecutor = new ToolExecutor(
1274
1301
  toolFile.Tool,
1275
1302
  toolFile.default,
1276
1303
  this.ctx,
1277
- schemas // Auto-generated schemas from manifest (resolved from definitions)
1304
+ schemas, // Auto-generated schemas from manifest (resolved from definitions)
1305
+ allSchemas
1278
1306
  );
1279
1307
  this.tools[toolInstanceName] = toolExecutor;
1280
1308
 
package/src/FlinkRepo.ts CHANGED
@@ -16,7 +16,7 @@ export abstract class FlinkRepo<C extends FlinkContext, Model extends Document>
16
16
  this._ctx = ctx as C;
17
17
  }
18
18
 
19
- get ctx() {
19
+ get ctx(): C {
20
20
  if (!this._ctx) throw new Error("Missing FlinkContext");
21
21
  return this._ctx;
22
22
  }
@@ -1,12 +1,16 @@
1
1
  import { FlinkContext } from "../FlinkContext";
2
2
  import { forbidden } from "../FlinkErrors";
3
3
  import { FlinkLogFactory } from "../FlinkLogFactory";
4
+ import { getRequestContext, getRequestUser } from "../FlinkRequestContext";
4
5
  import { AgentRunner } from "./AgentRunner";
5
6
  import { FlinkToolFile, FlinkToolProps } from "./FlinkTool";
7
+ import { resolveInstructionsReturn, type InstructionsReturn } from "./instructionFileLoader";
8
+ export type { InstructionsReturn } from "./instructionFileLoader";
6
9
  import { LLMContentBlock, LLMMessage } from "./LLMAdapter";
7
10
  import { ToolExecutor } from "./ToolExecutor";
8
11
 
9
12
  const logger = FlinkLogFactory.createLogger("flink.ai.flink-agent");
13
+ const instructionsLog = FlinkLogFactory.createLogger("flink.ai.instructions");
10
14
 
11
15
  /**
12
16
  * Callback function for dynamic instruction generation
@@ -261,7 +265,50 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
261
265
  // Abstract properties (must be defined by subclass)
262
266
  abstract id: string;
263
267
  abstract description: string;
264
- abstract instructions: AgentInstructions<Ctx>;
268
+
269
+ /**
270
+ * Define the agent's instructions. Override this method in your agent subclass.
271
+ *
272
+ * `ctx` is automatically typed as your app's context — no annotation needed.
273
+ *
274
+ * Supported return values:
275
+ * - `string` — Plain text used as-is
276
+ * - `string` ending with a known text extension (`.md`, `.txt`, `.yaml`, `.yml`, `.xml`, …) — Auto-loaded from disk (project-root-relative)
277
+ * - `{ file, params? }` — Explicitly load a file (any extension) with optional Handlebars template params
278
+ *
279
+ * @example Plain text
280
+ * instructions() {
281
+ * return "You are a helpful car assistant.";
282
+ * }
283
+ *
284
+ * @example Dynamic instructions using ctx and agentContext
285
+ * async instructions(ctx, agentContext) {
286
+ * const user = await ctx.repos.userRepo.getById(agentContext.user?.id);
287
+ * return `You are a support agent for ${user.name}.`;
288
+ * }
289
+ *
290
+ * @example Auto-load file by extension (.md, .txt, .yaml, .yml, .xml, … — path relative to project root)
291
+ * instructions() {
292
+ * return "src/agents/instructions/car-agent.md";
293
+ * }
294
+ *
295
+ * @example File with template params
296
+ * async instructions(ctx, agentContext) {
297
+ * return {
298
+ * file: "src/agents/instructions/support.md",
299
+ * params: {
300
+ * customerTier: agentContext.user?.tier || "standard",
301
+ * isBusinessHours: new Date().getHours() >= 9,
302
+ * },
303
+ * };
304
+ * }
305
+ *
306
+ * @example Agent-file-relative path (use agentInstructions helper)
307
+ * instructions(ctx, agentContext) {
308
+ * return agentInstructions("./instructions/support.md", { date: new Date() })(ctx, agentContext);
309
+ * }
310
+ */
311
+ abstract instructions(ctx: Ctx, agentContext: AgentExecuteContext): Promise<InstructionsReturn> | InstructionsReturn;
265
312
 
266
313
  // Optional properties
267
314
  tools?: Array<string | FlinkToolFile | FlinkToolProps>; // Tool ids, tool file references, or tool props (defaults to empty array)
@@ -471,9 +518,9 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
471
518
  * for await (const chunk of response.fullStream) { ... } // Stream all events
472
519
  */
473
520
  protected execute(input: AgentExecuteInput<ConversationCtx>): AgentResponse {
474
- // Use bound user if not explicitly provided in input
475
- const user = input.user ?? this._boundUser;
476
- const userPermissions = input.userPermissions ?? this._boundUserPermissions;
521
+ // Use bound user if not explicitly provided in input, fall back to AsyncLocalStorage request context
522
+ const user = input.user ?? this._boundUser ?? getRequestUser();
523
+ const userPermissions = input.userPermissions ?? this._boundUserPermissions ?? getRequestContext()?.userPermissions;
477
524
  const conversationContext = input.conversationContext ?? this._boundConversationContext;
478
525
  const executeInput = { ...input, user, userPermissions, conversationContext };
479
526
 
@@ -699,7 +746,11 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
699
746
  return {
700
747
  id: this.getAgentId(),
701
748
  description: this.description,
702
- instructions: this.instructions,
749
+ instructions: async (ctx, agentContext) => {
750
+ const resolved = await resolveInstructionsReturn(await Promise.resolve(this.instructions(ctx as Ctx, agentContext)), ctx, agentContext);
751
+ instructionsLog.debug(`[${this.getAgentId()}] Resolved instructions:\n${resolved}`);
752
+ return resolved;
753
+ },
703
754
  tools: this.tools,
704
755
  model: this.model,
705
756
  limits: this.limits,
@@ -2,7 +2,7 @@ import Ajv from "ajv";
2
2
  import { FlinkContext } from "../FlinkContext";
3
3
  import { forbidden } from "../FlinkErrors";
4
4
  import { FlinkLogFactory } from "../FlinkLogFactory";
5
- import { getRequestPermissions, getRequestUser } from "../FlinkRequestContext";
5
+ import { getRequestContext, getRequestUser } from "../FlinkRequestContext";
6
6
  import { FlinkTool, FlinkToolProps, ToolResult } from "./FlinkTool";
7
7
  import { FlinkToolSchema } from "./LLMAdapter";
8
8
 
@@ -10,6 +10,8 @@ const toolLog = FlinkLogFactory.createLogger("flink.ai.tool");
10
10
 
11
11
  export class ToolExecutor<Ctx extends FlinkContext> {
12
12
  private ajv = new Ajv({ allErrors: true });
13
+ private compiledInputValidator?: ReturnType<Ajv["compile"]>;
14
+ private compiledOutputValidator?: ReturnType<Ajv["compile"]>;
13
15
 
14
16
  constructor(
15
17
  private toolProps: FlinkToolProps,
@@ -20,8 +22,34 @@ export class ToolExecutor<Ctx extends FlinkContext> {
20
22
  outputSchema?: any;
21
23
  inputTypeHint?: 'void' | 'any' | 'named';
22
24
  outputTypeHint?: 'void' | 'any' | 'named';
23
- }
25
+ },
26
+ allSchemas?: Record<string, any>
24
27
  ) {
28
+ // Pre-populate AJV with all schemas so $ref references resolve across schema boundaries
29
+ if (allSchemas) {
30
+ for (const schema of Object.values(allSchemas)) {
31
+ if (schema && schema.$id) {
32
+ try {
33
+ this.ajv.addSchema(schema);
34
+ } catch {
35
+ // Ignore duplicate schema errors (schema may already be added)
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ // Pre-compile validators once at construction time (not per invocation)
42
+ if (toolProps.inputJsonSchema) {
43
+ this.compiledInputValidator = this.ajv.compile(toolProps.inputJsonSchema);
44
+ } else if (autoSchemas?.inputSchema) {
45
+ this.compiledInputValidator = this.ajv.compile(autoSchemas.inputSchema);
46
+ }
47
+
48
+ if (toolProps.outputJsonSchema) {
49
+ this.compiledOutputValidator = this.ajv.compile(toolProps.outputJsonSchema);
50
+ } else if (autoSchemas?.outputSchema) {
51
+ this.compiledOutputValidator = this.ajv.compile(autoSchemas.outputSchema);
52
+ }
25
53
  // Log when using auto-schemas
26
54
  if (autoSchemas?.inputSchema && !toolProps.inputSchema && !toolProps.inputJsonSchema) {
27
55
  toolLog.debug(`Tool ${toolProps.id}: Using auto-generated schemas from type parameters`);
@@ -56,7 +84,7 @@ export class ToolExecutor<Ctx extends FlinkContext> {
56
84
  async execute(input: any, overrides?: { user?: any; permissions?: string[]; conversationContext?: any }): Promise<ToolResult<any>> {
57
85
  // Get user, permissions, and conversationContext from AsyncLocalStorage or overrides
58
86
  const user = overrides?.user ?? getRequestUser();
59
- const userPermissions = overrides?.permissions ?? getRequestPermissions();
87
+ const userPermissions = overrides?.permissions ?? getRequestContext()?.userPermissions;
60
88
  const conversationContext = overrides?.conversationContext;
61
89
 
62
90
  // 1. Permission check
@@ -74,12 +102,11 @@ export class ToolExecutor<Ctx extends FlinkContext> {
74
102
  if (this.toolProps.inputSchema) {
75
103
  // Priority 1: Use Zod validation
76
104
  validatedInput = this.toolProps.inputSchema.parse(input);
77
- } else if (this.toolProps.inputJsonSchema) {
78
- // Priority 2: Use manual JSON Schema validation
79
- const validate = this.ajv.compile(this.toolProps.inputJsonSchema);
80
- const valid = validate(input);
105
+ } else if (this.compiledInputValidator) {
106
+ // Priority 2 & 3: Use pre-compiled JSON Schema validator (manual or auto-generated)
107
+ const valid = this.compiledInputValidator(input);
81
108
  if (!valid) {
82
- const errorDetails = this.formatAjvErrors(validate.errors || [], input);
109
+ const errorDetails = this.formatAjvErrors(this.compiledInputValidator.errors || [], input);
83
110
  toolLog.warn(`Tool ${this.toolProps.id} input validation failed:`, errorDetails);
84
111
  return {
85
112
  success: false,
@@ -88,20 +115,6 @@ export class ToolExecutor<Ctx extends FlinkContext> {
88
115
  };
89
116
  }
90
117
  validatedInput = input;
91
- } else if (this.autoSchemas?.inputSchema) {
92
- // Priority 3: Use auto-generated JSON Schema validation
93
- const validate = this.ajv.compile(this.autoSchemas.inputSchema);
94
- const valid = validate(input);
95
- if (!valid) {
96
- const errorDetails = this.formatAjvErrors(validate.errors || [], input);
97
- toolLog.warn(`Tool ${this.toolProps.id} input validation failed (auto-generated schema):`, errorDetails);
98
- return {
99
- success: false,
100
- error: `Invalid input for tool '${this.toolProps.id}': ${errorDetails}`,
101
- code: "VALIDATION_ERROR",
102
- };
103
- }
104
- validatedInput = input;
105
118
  } else {
106
119
  // No schema available - skip validation
107
120
  validatedInput = input;
@@ -161,13 +174,12 @@ export class ToolExecutor<Ctx extends FlinkContext> {
161
174
  code: "OUTPUT_VALIDATION_ERROR",
162
175
  };
163
176
  }
164
- } else if (this.toolProps.outputJsonSchema) {
165
- // Priority 2: Use manual JSON Schema validation
177
+ } else if (this.compiledOutputValidator) {
178
+ // Priority 2 & 3: Use pre-compiled JSON Schema validator (manual or auto-generated)
166
179
  try {
167
- const validate = this.ajv.compile(this.toolProps.outputJsonSchema);
168
- const valid = validate(result.data);
180
+ const valid = this.compiledOutputValidator(result.data);
169
181
  if (!valid) {
170
- const errorDetails = this.formatAjvErrors(validate.errors || []);
182
+ const errorDetails = this.formatAjvErrors(this.compiledOutputValidator.errors || []);
171
183
  toolLog.error(`Tool ${this.toolProps.id} output validation failed:`, errorDetails);
172
184
  return {
173
185
  success: false,
@@ -184,29 +196,6 @@ export class ToolExecutor<Ctx extends FlinkContext> {
184
196
  code: "OUTPUT_VALIDATION_ERROR",
185
197
  };
186
198
  }
187
- } else if (this.autoSchemas?.outputSchema) {
188
- // Priority 3: Use auto-generated JSON Schema validation
189
- try {
190
- const validate = this.ajv.compile(this.autoSchemas.outputSchema);
191
- const valid = validate(result.data);
192
- if (!valid) {
193
- const errorDetails = this.formatAjvErrors(validate.errors || []);
194
- toolLog.error(`Tool ${this.toolProps.id} output validation failed (auto-generated schema):`, errorDetails);
195
- return {
196
- success: false,
197
- error: `Invalid output from tool ${this.toolProps.id}: ${errorDetails}`,
198
- code: "OUTPUT_VALIDATION_ERROR",
199
- };
200
- }
201
- return { success: true, data: result.data };
202
- } catch (err: any) {
203
- toolLog.error(`Tool ${this.toolProps.id} output validation failed:`, err.message);
204
- return {
205
- success: false,
206
- error: `Invalid output from tool ${this.toolProps.id}: ${err.message}`,
207
- code: "OUTPUT_VALIDATION_ERROR",
208
- };
209
- }
210
199
  }
211
200
 
212
201
  // No output validation - return result as-is
@@ -242,4 +242,4 @@ export function agentInstructions<Ctx extends FlinkContext = FlinkContext>(
242
242
  }
243
243
 
244
244
  // Re-export types for convenience
245
- export type { InstructionsCallback, AgentExecuteContext } from "./FlinkAgent";
245
+ export type { InstructionsCallback, AgentExecuteContext, InstructionsReturn } from "./FlinkAgent";
@@ -0,0 +1,126 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import Handlebars from "handlebars";
4
+
5
+ /**
6
+ * Return type for the FlinkAgent.instructions() method.
7
+ *
8
+ * Supported forms:
9
+ * - `string` — Plain text used as-is, OR a file path (see below) which is auto-loaded.
10
+ * - `{ file, params? }` — Explicitly load a file with optional Handlebars template params.
11
+ *
12
+ * **Path resolution** — all paths resolve relative to the **project root** (`process.cwd()`):
13
+ * - `"instructions/foo.md"` → `<project-root>/instructions/foo.md`
14
+ * - `"./instructions/foo.md"` → `<project-root>/instructions/foo.md`
15
+ * - `"/instructions/foo.md"` → `<project-root>/instructions/foo.md` (leading slash stripped)
16
+ *
17
+ * Auto-loaded string extensions: `.md`, `.txt`, `.yaml`, `.yml`, `.xml`, `.toml`, `.ini`, `.json`, `.html`
18
+ *
19
+ * @example Plain text
20
+ * instructions() {
21
+ * return "You are a helpful car assistant.";
22
+ * }
23
+ *
24
+ * @example Auto-load file (project-root-relative)
25
+ * instructions() {
26
+ * return "instructions/car-agent.md";
27
+ * }
28
+ *
29
+ * @example File with template params
30
+ * async instructions(_ctx, agentCtx) {
31
+ * return {
32
+ * file: "instructions/support.md",
33
+ * params: {
34
+ * customerTier: agentCtx.user?.tier || "standard",
35
+ * isBusinessHours: new Date().getHours() >= 9,
36
+ * },
37
+ * };
38
+ * }
39
+ */
40
+ export type InstructionsReturn = string | { file: string; params?: Record<string, any> };
41
+
42
+ interface FileCacheEntry {
43
+ content: string;
44
+ mtime: number;
45
+ compiledTemplate?: Handlebars.TemplateDelegate;
46
+ hasTemplateExpressions?: boolean;
47
+ }
48
+
49
+ const fileCache = new Map<string, FileCacheEntry>();
50
+
51
+ /**
52
+ * Resolve a file path relative to project root (process.cwd()).
53
+ * Leading `./` and `/` are normalised away — all paths are treated as project-root-relative.
54
+ */
55
+ function resolveFilePath(filePath: string): string {
56
+ // Strip leading slash so "/foo.md" behaves the same as "foo.md"
57
+ const normalised = filePath.replace(/^\/+/, "");
58
+ return path.resolve(process.cwd(), normalised);
59
+ }
60
+
61
+ function loadFile(filePath: string): FileCacheEntry {
62
+ const resolvedPath = resolveFilePath(filePath);
63
+ try {
64
+ const stats = fs.statSync(resolvedPath);
65
+ const mtime = stats.mtimeMs;
66
+
67
+ const cached = fileCache.get(resolvedPath);
68
+ if (cached && cached.mtime === mtime) {
69
+ return cached;
70
+ }
71
+
72
+ const content = fs.readFileSync(resolvedPath, "utf-8");
73
+ const entry: FileCacheEntry = { content, mtime };
74
+ fileCache.set(resolvedPath, entry);
75
+ return entry;
76
+ } catch (err: any) {
77
+ if (err.code === "ENOENT") {
78
+ throw new Error(`Agent instructions file not found: ${resolvedPath} (from: ${filePath})`);
79
+ }
80
+ throw new Error(`Failed to load agent instructions file: ${resolvedPath} - ${err.message}`);
81
+ }
82
+ }
83
+
84
+ function renderTemplate(entry: FileCacheEntry, data: any): string {
85
+ if (entry.hasTemplateExpressions === false) {
86
+ return entry.content;
87
+ }
88
+
89
+ if (!entry.compiledTemplate) {
90
+ entry.hasTemplateExpressions = /\{\{/.test(entry.content);
91
+ if (!entry.hasTemplateExpressions) {
92
+ return entry.content;
93
+ }
94
+ entry.compiledTemplate = Handlebars.compile(entry.content);
95
+ }
96
+
97
+ return entry.compiledTemplate(data);
98
+ }
99
+
100
+ const TEXT_FILE_EXTENSIONS = [".md", ".txt", ".yaml", ".yml", ".xml", ".toml", ".ini", ".json", ".html", ".htm"];
101
+
102
+ function isTextFilePath(value: string): boolean {
103
+ const trimmed = value.trimEnd().toLowerCase();
104
+ return TEXT_FILE_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
105
+ }
106
+
107
+ /**
108
+ * Resolve an InstructionsReturn value to a plain string.
109
+ * @internal Used by FlinkAgent.toAgentProps()
110
+ */
111
+ export async function resolveInstructionsReturn(result: InstructionsReturn, ctx: any, agentContext: any): Promise<string> {
112
+ if (typeof result === "string") {
113
+ if (isTextFilePath(result)) {
114
+ const entry = loadFile(result.trimEnd());
115
+ const templateData = { ctx, agentContext, user: agentContext?.user };
116
+ return renderTemplate(entry, templateData);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ // { file, params? }
122
+ const { file, params } = result;
123
+ const entry = loadFile(file);
124
+ const templateData = { ...params, ctx, agentContext, user: agentContext?.user };
125
+ return renderTemplate(entry, templateData);
126
+ }