@flink-app/flink 2.0.0-alpha.60 → 2.0.0-alpha.62

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 2.0.0-alpha.62
4
+
5
+ ### Minor Changes
6
+
7
+ - Add file-based agent instructions with Handlebars templating
8
+
9
+ The `instructions()` method now supports returning a file path or `{ file, params? }` object:
10
+
11
+ - Return a string ending in `.md`, `.txt`, `.yaml`, `.yml`, `.xml`, etc. to auto-load from disk
12
+ - Return `{ file: "instructions/agent.md", params: { tier: "premium" } }` for Handlebars templates
13
+ - All paths resolve relative to the project root (`process.cwd()`)
14
+ - Files are cached with mtime-based invalidation
15
+ - Template variables `{{ctx}}`, `{{agentContext}}`, and `{{user}}` are always available
16
+
17
+ Also fixes a bug where `getRequestPermissions()` returning `[]` outside a request context
18
+ would override `user.permissions` in the fallback chain, causing permission checks to fail.
19
+
20
+ ## 2.0.0-alpha.61
21
+
3
22
  ## 2.0.0-alpha.60
4
23
 
5
24
  ## 2.0.0-alpha.59
@@ -931,7 +931,7 @@ var FlinkApp = /** @class */ (function () {
931
931
  };
932
932
  FlinkApp.prototype.registerAutoRegisterableTools = function () {
933
933
  return __awaiter(this, void 0, void 0, function () {
934
- var ToolExecutor, getRepoInstanceName, schemaManifest, _i, autoRegisteredTools_1, toolFile, toolId, toolInstanceName, metadata, schemas, toolExecutor;
934
+ var ToolExecutor, getRepoInstanceName, schemaManifest, _i, autoRegisteredTools_1, toolFile, toolId, toolInstanceName, metadata, schemas, allSchemas, toolExecutor;
935
935
  return __generator(this, function (_a) {
936
936
  ToolExecutor = require("./ai/ToolExecutor").ToolExecutor;
937
937
  getRepoInstanceName = require("./utils").getRepoInstanceName;
@@ -968,8 +968,9 @@ var FlinkApp = /** @class */ (function () {
968
968
  if ((metadata === null || metadata === void 0 ? void 0 : metadata.outputSchemaName) && !(schemas === null || schemas === void 0 ? void 0 : schemas.outputSchema)) {
969
969
  FlinkLog_1.log.warn("Tool ".concat(toolFile.__file, " references outputSchema \"").concat(metadata.outputSchemaName, "\" but not found in schema universe"));
970
970
  }
971
- toolExecutor = new ToolExecutor(toolFile.Tool, toolFile.default, this.ctx, schemas // Auto-generated schemas from manifest (resolved from definitions)
972
- );
971
+ allSchemas = schemaManifest.version === "2.0" ? schemaManifest.schemas : schemaManifest.definitions;
972
+ toolExecutor = new ToolExecutor(toolFile.Tool, toolFile.default, this.ctx, schemas, // Auto-generated schemas from manifest (resolved from definitions)
973
+ allSchemas);
973
974
  this.tools[toolInstanceName] = toolExecutor;
974
975
  initLog.info("Registered tool ".concat(toolInstanceName, " (").concat(toolId, ")"));
975
976
  }
@@ -14,7 +14,7 @@ export declare abstract class FlinkRepo<C extends FlinkContext, Model extends Do
14
14
  collection: Collection;
15
15
  private _ctx?;
16
16
  set ctx(ctx: FlinkContext);
17
- get ctx(): FlinkContext;
17
+ get ctx(): C;
18
18
  constructor(collectionName: string, db: Db, client?: MongoClient | undefined);
19
19
  findAll(query?: {}): Promise<Model[]>;
20
20
  getById(id: string | ObjectId): Promise<Model | null>;
@@ -1,5 +1,7 @@
1
1
  import { FlinkContext } from "../FlinkContext";
2
2
  import { FlinkToolFile, FlinkToolProps } from "./FlinkTool";
3
+ import { type InstructionsReturn } from "./instructionFileLoader";
4
+ export type { InstructionsReturn } from "./instructionFileLoader";
3
5
  import { LLMContentBlock, LLMMessage } from "./LLMAdapter";
4
6
  import { ToolExecutor } from "./ToolExecutor";
5
7
  /**
@@ -241,7 +243,49 @@ export declare abstract class FlinkAgent<Ctx extends FlinkContext, ConversationC
241
243
  private _tools?;
242
244
  abstract id: string;
243
245
  abstract description: string;
244
- abstract instructions: AgentInstructions<Ctx>;
246
+ /**
247
+ * Define the agent's instructions. Override this method in your agent subclass.
248
+ *
249
+ * `ctx` is automatically typed as your app's context — no annotation needed.
250
+ *
251
+ * Supported return values:
252
+ * - `string` — Plain text used as-is
253
+ * - `string` ending with a known text extension (`.md`, `.txt`, `.yaml`, `.yml`, `.xml`, …) — Auto-loaded from disk (project-root-relative)
254
+ * - `{ file, params? }` — Explicitly load a file (any extension) with optional Handlebars template params
255
+ *
256
+ * @example Plain text
257
+ * instructions() {
258
+ * return "You are a helpful car assistant.";
259
+ * }
260
+ *
261
+ * @example Dynamic instructions using ctx and agentContext
262
+ * async instructions(ctx, agentContext) {
263
+ * const user = await ctx.repos.userRepo.getById(agentContext.user?.id);
264
+ * return `You are a support agent for ${user.name}.`;
265
+ * }
266
+ *
267
+ * @example Auto-load file by extension (.md, .txt, .yaml, .yml, .xml, … — path relative to project root)
268
+ * instructions() {
269
+ * return "src/agents/instructions/car-agent.md";
270
+ * }
271
+ *
272
+ * @example File with template params
273
+ * async instructions(ctx, agentContext) {
274
+ * return {
275
+ * file: "src/agents/instructions/support.md",
276
+ * params: {
277
+ * customerTier: agentContext.user?.tier || "standard",
278
+ * isBusinessHours: new Date().getHours() >= 9,
279
+ * },
280
+ * };
281
+ * }
282
+ *
283
+ * @example Agent-file-relative path (use agentInstructions helper)
284
+ * instructions(ctx, agentContext) {
285
+ * return agentInstructions("./instructions/support.md", { date: new Date() })(ctx, agentContext);
286
+ * }
287
+ */
288
+ abstract instructions(ctx: Ctx, agentContext: AgentExecuteContext): Promise<InstructionsReturn> | InstructionsReturn;
245
289
  tools?: Array<string | FlinkToolFile | FlinkToolProps>;
246
290
  model?: {
247
291
  adapterId?: string;
@@ -70,8 +70,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
70
70
  exports.FlinkAgent = void 0;
71
71
  var FlinkErrors_1 = require("../FlinkErrors");
72
72
  var FlinkLogFactory_1 = require("../FlinkLogFactory");
73
+ var FlinkRequestContext_1 = require("../FlinkRequestContext");
73
74
  var AgentRunner_1 = require("./AgentRunner");
75
+ var instructionFileLoader_1 = require("./instructionFileLoader");
74
76
  var logger = FlinkLogFactory_1.FlinkLogFactory.createLogger("flink.ai.flink-agent");
77
+ var instructionsLog = FlinkLogFactory_1.FlinkLogFactory.createLogger("flink.ai.instructions");
75
78
  /**
76
79
  * Base class for Flink agents (similar to FlinkRepo pattern)
77
80
  *
@@ -340,11 +343,11 @@ var FlinkAgent = /** @class */ (function () {
340
343
  */
341
344
  FlinkAgent.prototype.execute = function (input) {
342
345
  var _this = this;
343
- var _a, _b, _c;
344
- // Use bound user if not explicitly provided in input
345
- var user = (_a = input.user) !== null && _a !== void 0 ? _a : this._boundUser;
346
- var userPermissions = (_b = input.userPermissions) !== null && _b !== void 0 ? _b : this._boundUserPermissions;
347
- var conversationContext = (_c = input.conversationContext) !== null && _c !== void 0 ? _c : this._boundConversationContext;
346
+ var _a, _b, _c, _d, _e, _f;
347
+ // Use bound user if not explicitly provided in input, fall back to AsyncLocalStorage request context
348
+ var user = (_b = (_a = input.user) !== null && _a !== void 0 ? _a : this._boundUser) !== null && _b !== void 0 ? _b : (0, FlinkRequestContext_1.getRequestUser)();
349
+ var userPermissions = (_d = (_c = input.userPermissions) !== null && _c !== void 0 ? _c : this._boundUserPermissions) !== null && _d !== void 0 ? _d : (_e = (0, FlinkRequestContext_1.getRequestContext)()) === null || _e === void 0 ? void 0 : _e.userPermissions;
350
+ var conversationContext = (_f = input.conversationContext) !== null && _f !== void 0 ? _f : this._boundConversationContext;
348
351
  var executeInput = __assign(__assign({}, input), { user: user, userPermissions: userPermissions, conversationContext: conversationContext });
349
352
  // Permission check
350
353
  if (this.permissions) {
@@ -620,11 +623,26 @@ var FlinkAgent = /** @class */ (function () {
620
623
  return this.id;
621
624
  };
622
625
  FlinkAgent.prototype.toAgentProps = function () {
626
+ var _this = this;
623
627
  var _a, _b, _c, _d, _e;
624
628
  return {
625
629
  id: this.getAgentId(),
626
630
  description: this.description,
627
- instructions: this.instructions,
631
+ instructions: function (ctx, agentContext) { return __awaiter(_this, void 0, void 0, function () {
632
+ var resolved, _a;
633
+ return __generator(this, function (_b) {
634
+ switch (_b.label) {
635
+ case 0:
636
+ _a = instructionFileLoader_1.resolveInstructionsReturn;
637
+ return [4 /*yield*/, Promise.resolve(this.instructions(ctx, agentContext))];
638
+ case 1: return [4 /*yield*/, _a.apply(void 0, [_b.sent(), ctx, agentContext])];
639
+ case 2:
640
+ resolved = _b.sent();
641
+ instructionsLog.debug("[".concat(this.getAgentId(), "] Resolved instructions:\n").concat(resolved));
642
+ return [2 /*return*/, resolved];
643
+ }
644
+ });
645
+ }); },
628
646
  tools: this.tools,
629
647
  model: this.model,
630
648
  limits: this.limits,
@@ -7,12 +7,14 @@ export declare class ToolExecutor<Ctx extends FlinkContext> {
7
7
  private ctx;
8
8
  private autoSchemas?;
9
9
  private ajv;
10
+ private compiledInputValidator?;
11
+ private compiledOutputValidator?;
10
12
  constructor(toolProps: FlinkToolProps, toolFn: FlinkTool<Ctx, any, any>, ctx: Ctx, autoSchemas?: {
11
13
  inputSchema?: any;
12
14
  outputSchema?: any;
13
15
  inputTypeHint?: "void" | "any" | "named";
14
16
  outputTypeHint?: "void" | "any" | "named";
15
- } | undefined);
17
+ } | undefined, allSchemas?: Record<string, any>);
16
18
  /**
17
19
  * Execute the tool with input
18
20
  * @param input - Tool input data
@@ -46,12 +46,39 @@ var FlinkLogFactory_1 = require("../FlinkLogFactory");
46
46
  var FlinkRequestContext_1 = require("../FlinkRequestContext");
47
47
  var toolLog = FlinkLogFactory_1.FlinkLogFactory.createLogger("flink.ai.tool");
48
48
  var ToolExecutor = /** @class */ (function () {
49
- function ToolExecutor(toolProps, toolFn, ctx, autoSchemas) {
49
+ function ToolExecutor(toolProps, toolFn, ctx, autoSchemas, allSchemas) {
50
50
  this.toolProps = toolProps;
51
51
  this.toolFn = toolFn;
52
52
  this.ctx = ctx;
53
53
  this.autoSchemas = autoSchemas;
54
54
  this.ajv = new ajv_1.default({ allErrors: true });
55
+ // Pre-populate AJV with all schemas so $ref references resolve across schema boundaries
56
+ if (allSchemas) {
57
+ for (var _i = 0, _a = Object.values(allSchemas); _i < _a.length; _i++) {
58
+ var schema = _a[_i];
59
+ if (schema && schema.$id) {
60
+ try {
61
+ this.ajv.addSchema(schema);
62
+ }
63
+ catch (_b) {
64
+ // Ignore duplicate schema errors (schema may already be added)
65
+ }
66
+ }
67
+ }
68
+ }
69
+ // Pre-compile validators once at construction time (not per invocation)
70
+ if (toolProps.inputJsonSchema) {
71
+ this.compiledInputValidator = this.ajv.compile(toolProps.inputJsonSchema);
72
+ }
73
+ else if (autoSchemas === null || autoSchemas === void 0 ? void 0 : autoSchemas.inputSchema) {
74
+ this.compiledInputValidator = this.ajv.compile(autoSchemas.inputSchema);
75
+ }
76
+ if (toolProps.outputJsonSchema) {
77
+ this.compiledOutputValidator = this.ajv.compile(toolProps.outputJsonSchema);
78
+ }
79
+ else if (autoSchemas === null || autoSchemas === void 0 ? void 0 : autoSchemas.outputSchema) {
80
+ this.compiledOutputValidator = this.ajv.compile(autoSchemas.outputSchema);
81
+ }
55
82
  // Log when using auto-schemas
56
83
  if ((autoSchemas === null || autoSchemas === void 0 ? void 0 : autoSchemas.inputSchema) && !toolProps.inputSchema && !toolProps.inputJsonSchema) {
57
84
  toolLog.debug("Tool ".concat(toolProps.id, ": Using auto-generated schemas from type parameters"));
@@ -80,34 +107,33 @@ var ToolExecutor = /** @class */ (function () {
80
107
  */
81
108
  ToolExecutor.prototype.execute = function (input, overrides) {
82
109
  return __awaiter(this, void 0, void 0, function () {
83
- var user, userPermissions, conversationContext, hasPermission, validatedInput, validate, valid, errorDetails, validate, valid, errorDetails, errorDetails, result, err_1, validatedData, validate, valid, errorDetails, validate, valid, errorDetails;
84
- var _a, _b, _c, _d;
85
- return __generator(this, function (_e) {
86
- switch (_e.label) {
110
+ var user, userPermissions, conversationContext, hasPermission, validatedInput, valid, errorDetails, errorDetails, result, err_1, validatedData, valid, errorDetails;
111
+ var _a, _b, _c;
112
+ return __generator(this, function (_d) {
113
+ switch (_d.label) {
87
114
  case 0:
88
115
  user = (_a = overrides === null || overrides === void 0 ? void 0 : overrides.user) !== null && _a !== void 0 ? _a : (0, FlinkRequestContext_1.getRequestUser)();
89
- userPermissions = (_b = overrides === null || overrides === void 0 ? void 0 : overrides.permissions) !== null && _b !== void 0 ? _b : (0, FlinkRequestContext_1.getRequestPermissions)();
116
+ userPermissions = (_b = overrides === null || overrides === void 0 ? void 0 : overrides.permissions) !== null && _b !== void 0 ? _b : (_c = (0, FlinkRequestContext_1.getRequestContext)()) === null || _c === void 0 ? void 0 : _c.userPermissions;
90
117
  conversationContext = overrides === null || overrides === void 0 ? void 0 : overrides.conversationContext;
91
118
  if (!this.toolProps.permissions) return [3 /*break*/, 2];
92
119
  return [4 /*yield*/, this.checkPermissionsInternal(input, user, userPermissions)];
93
120
  case 1:
94
- hasPermission = _e.sent();
121
+ hasPermission = _d.sent();
95
122
  if (!hasPermission) {
96
123
  toolLog.debug("Tool invocator is missing required permission(s)", this.toolProps.permissions, "user has", userPermissions);
97
124
  throw (0, FlinkErrors_1.forbidden)("Permission denied for tool ".concat(this.toolProps.id), "PERMISSION_DENIED");
98
125
  }
99
- _e.label = 2;
126
+ _d.label = 2;
100
127
  case 2:
101
128
  try {
102
129
  if (this.toolProps.inputSchema) {
103
130
  // Priority 1: Use Zod validation
104
131
  validatedInput = this.toolProps.inputSchema.parse(input);
105
132
  }
106
- else if (this.toolProps.inputJsonSchema) {
107
- validate = this.ajv.compile(this.toolProps.inputJsonSchema);
108
- valid = validate(input);
133
+ else if (this.compiledInputValidator) {
134
+ valid = this.compiledInputValidator(input);
109
135
  if (!valid) {
110
- errorDetails = this.formatAjvErrors(validate.errors || [], input);
136
+ errorDetails = this.formatAjvErrors(this.compiledInputValidator.errors || [], input);
111
137
  toolLog.warn("Tool ".concat(this.toolProps.id, " input validation failed:"), errorDetails);
112
138
  return [2 /*return*/, {
113
139
  success: false,
@@ -117,20 +143,6 @@ var ToolExecutor = /** @class */ (function () {
117
143
  }
118
144
  validatedInput = input;
119
145
  }
120
- else if ((_c = this.autoSchemas) === null || _c === void 0 ? void 0 : _c.inputSchema) {
121
- validate = this.ajv.compile(this.autoSchemas.inputSchema);
122
- valid = validate(input);
123
- if (!valid) {
124
- errorDetails = this.formatAjvErrors(validate.errors || [], input);
125
- toolLog.warn("Tool ".concat(this.toolProps.id, " input validation failed (auto-generated schema):"), errorDetails);
126
- return [2 /*return*/, {
127
- success: false,
128
- error: "Invalid input for tool '".concat(this.toolProps.id, "': ").concat(errorDetails),
129
- code: "VALIDATION_ERROR",
130
- }];
131
- }
132
- validatedInput = input;
133
- }
134
146
  else {
135
147
  // No schema available - skip validation
136
148
  validatedInput = input;
@@ -147,9 +159,9 @@ var ToolExecutor = /** @class */ (function () {
147
159
  }
148
160
  // 3. Execute tool
149
161
  toolLog.debug("Executing tool ".concat(this.toolProps.id));
150
- _e.label = 3;
162
+ _d.label = 3;
151
163
  case 3:
152
- _e.trys.push([3, 5, , 6]);
164
+ _d.trys.push([3, 5, , 6]);
153
165
  toolLog.trace(this.toolFn.name + " input:", validatedInput);
154
166
  return [4 /*yield*/, this.toolFn({
155
167
  input: validatedInput,
@@ -159,10 +171,10 @@ var ToolExecutor = /** @class */ (function () {
159
171
  conversationCtx: conversationContext,
160
172
  })];
161
173
  case 4:
162
- result = _e.sent();
174
+ result = _d.sent();
163
175
  return [3 /*break*/, 6];
164
176
  case 5:
165
- err_1 = _e.sent();
177
+ err_1 = _d.sent();
166
178
  toolLog.error("Tool ".concat(this.toolProps.id, " threw error:"), err_1.message);
167
179
  return [2 /*return*/, {
168
180
  success: false,
@@ -191,13 +203,12 @@ var ToolExecutor = /** @class */ (function () {
191
203
  }];
192
204
  }
193
205
  }
194
- else if (this.toolProps.outputJsonSchema) {
195
- // Priority 2: Use manual JSON Schema validation
206
+ else if (this.compiledOutputValidator) {
207
+ // Priority 2 & 3: Use pre-compiled JSON Schema validator (manual or auto-generated)
196
208
  try {
197
- validate = this.ajv.compile(this.toolProps.outputJsonSchema);
198
- valid = validate(result.data);
209
+ valid = this.compiledOutputValidator(result.data);
199
210
  if (!valid) {
200
- errorDetails = this.formatAjvErrors(validate.errors || []);
211
+ errorDetails = this.formatAjvErrors(this.compiledOutputValidator.errors || []);
201
212
  toolLog.error("Tool ".concat(this.toolProps.id, " output validation failed:"), errorDetails);
202
213
  return [2 /*return*/, {
203
214
  success: false,
@@ -216,31 +227,6 @@ var ToolExecutor = /** @class */ (function () {
216
227
  }];
217
228
  }
218
229
  }
219
- else if ((_d = this.autoSchemas) === null || _d === void 0 ? void 0 : _d.outputSchema) {
220
- // Priority 3: Use auto-generated JSON Schema validation
221
- try {
222
- validate = this.ajv.compile(this.autoSchemas.outputSchema);
223
- valid = validate(result.data);
224
- if (!valid) {
225
- errorDetails = this.formatAjvErrors(validate.errors || []);
226
- toolLog.error("Tool ".concat(this.toolProps.id, " output validation failed (auto-generated schema):"), errorDetails);
227
- return [2 /*return*/, {
228
- success: false,
229
- error: "Invalid output from tool ".concat(this.toolProps.id, ": ").concat(errorDetails),
230
- code: "OUTPUT_VALIDATION_ERROR",
231
- }];
232
- }
233
- return [2 /*return*/, { success: true, data: result.data }];
234
- }
235
- catch (err) {
236
- toolLog.error("Tool ".concat(this.toolProps.id, " output validation failed:"), err.message);
237
- return [2 /*return*/, {
238
- success: false,
239
- error: "Invalid output from tool ".concat(this.toolProps.id, ": ").concat(err.message),
240
- code: "OUTPUT_VALIDATION_ERROR",
241
- }];
242
- }
243
- }
244
230
  // No output validation - return result as-is
245
231
  return [2 /*return*/, result];
246
232
  }
@@ -65,4 +65,4 @@ import { InstructionsCallback, AgentExecuteContext } from "./FlinkAgent";
65
65
  * @returns InstructionsCallback compatible with FlinkAgent.instructions property
66
66
  */
67
67
  export declare function agentInstructions<Ctx extends FlinkContext = FlinkContext>(filePath: string, variables?: Record<string, any> | ((ctx: Ctx, agentContext: AgentExecuteContext) => Record<string, any> | Promise<Record<string, any>>)): InstructionsCallback<Ctx>;
68
- export type { InstructionsCallback, AgentExecuteContext } from "./FlinkAgent";
68
+ export type { InstructionsCallback, AgentExecuteContext, InstructionsReturn } from "./FlinkAgent";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Return type for the FlinkAgent.instructions() method.
3
+ *
4
+ * Supported forms:
5
+ * - `string` — Plain text used as-is, OR a file path (see below) which is auto-loaded.
6
+ * - `{ file, params? }` — Explicitly load a file with optional Handlebars template params.
7
+ *
8
+ * **Path resolution** — all paths resolve relative to the **project root** (`process.cwd()`):
9
+ * - `"instructions/foo.md"` → `<project-root>/instructions/foo.md`
10
+ * - `"./instructions/foo.md"` → `<project-root>/instructions/foo.md`
11
+ * - `"/instructions/foo.md"` → `<project-root>/instructions/foo.md` (leading slash stripped)
12
+ *
13
+ * Auto-loaded string extensions: `.md`, `.txt`, `.yaml`, `.yml`, `.xml`, `.toml`, `.ini`, `.json`, `.html`
14
+ *
15
+ * @example Plain text
16
+ * instructions() {
17
+ * return "You are a helpful car assistant.";
18
+ * }
19
+ *
20
+ * @example Auto-load file (project-root-relative)
21
+ * instructions() {
22
+ * return "instructions/car-agent.md";
23
+ * }
24
+ *
25
+ * @example File with template params
26
+ * async instructions(_ctx, agentCtx) {
27
+ * return {
28
+ * file: "instructions/support.md",
29
+ * params: {
30
+ * customerTier: agentCtx.user?.tier || "standard",
31
+ * isBusinessHours: new Date().getHours() >= 9,
32
+ * },
33
+ * };
34
+ * }
35
+ */
36
+ export type InstructionsReturn = string | {
37
+ file: string;
38
+ params?: Record<string, any>;
39
+ };
40
+ /**
41
+ * Resolve an InstructionsReturn value to a plain string.
42
+ * @internal Used by FlinkAgent.toAgentProps()
43
+ */
44
+ export declare function resolveInstructionsReturn(result: InstructionsReturn, ctx: any, agentContext: any): Promise<string>;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || function (mod) {
30
+ if (mod && mod.__esModule) return mod;
31
+ var result = {};
32
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
33
+ __setModuleDefault(result, mod);
34
+ return result;
35
+ };
36
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
37
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
38
+ return new (P || (P = Promise))(function (resolve, reject) {
39
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
40
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
41
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
42
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
43
+ });
44
+ };
45
+ var __generator = (this && this.__generator) || function (thisArg, body) {
46
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
47
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
48
+ function verb(n) { return function (v) { return step([n, v]); }; }
49
+ function step(op) {
50
+ if (f) throw new TypeError("Generator is already executing.");
51
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
52
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
53
+ if (y = 0, t) op = [op[0] & 2, t.value];
54
+ switch (op[0]) {
55
+ case 0: case 1: t = op; break;
56
+ case 4: _.label++; return { value: op[1], done: false };
57
+ case 5: _.label++; y = op[1]; op = [0]; continue;
58
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
59
+ default:
60
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
61
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
62
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
63
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
64
+ if (t[2]) _.ops.pop();
65
+ _.trys.pop(); continue;
66
+ }
67
+ op = body.call(thisArg, _);
68
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
69
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
70
+ }
71
+ };
72
+ var __importDefault = (this && this.__importDefault) || function (mod) {
73
+ return (mod && mod.__esModule) ? mod : { "default": mod };
74
+ };
75
+ Object.defineProperty(exports, "__esModule", { value: true });
76
+ exports.resolveInstructionsReturn = resolveInstructionsReturn;
77
+ var fs = __importStar(require("fs"));
78
+ var path = __importStar(require("path"));
79
+ var handlebars_1 = __importDefault(require("handlebars"));
80
+ var fileCache = new Map();
81
+ /**
82
+ * Resolve a file path relative to project root (process.cwd()).
83
+ * Leading `./` and `/` are normalised away — all paths are treated as project-root-relative.
84
+ */
85
+ function resolveFilePath(filePath) {
86
+ // Strip leading slash so "/foo.md" behaves the same as "foo.md"
87
+ var normalised = filePath.replace(/^\/+/, "");
88
+ return path.resolve(process.cwd(), normalised);
89
+ }
90
+ function loadFile(filePath) {
91
+ var resolvedPath = resolveFilePath(filePath);
92
+ try {
93
+ var stats = fs.statSync(resolvedPath);
94
+ var mtime = stats.mtimeMs;
95
+ var cached = fileCache.get(resolvedPath);
96
+ if (cached && cached.mtime === mtime) {
97
+ return cached;
98
+ }
99
+ var content = fs.readFileSync(resolvedPath, "utf-8");
100
+ var entry = { content: content, mtime: mtime };
101
+ fileCache.set(resolvedPath, entry);
102
+ return entry;
103
+ }
104
+ catch (err) {
105
+ if (err.code === "ENOENT") {
106
+ throw new Error("Agent instructions file not found: ".concat(resolvedPath, " (from: ").concat(filePath, ")"));
107
+ }
108
+ throw new Error("Failed to load agent instructions file: ".concat(resolvedPath, " - ").concat(err.message));
109
+ }
110
+ }
111
+ function renderTemplate(entry, data) {
112
+ if (entry.hasTemplateExpressions === false) {
113
+ return entry.content;
114
+ }
115
+ if (!entry.compiledTemplate) {
116
+ entry.hasTemplateExpressions = /\{\{/.test(entry.content);
117
+ if (!entry.hasTemplateExpressions) {
118
+ return entry.content;
119
+ }
120
+ entry.compiledTemplate = handlebars_1.default.compile(entry.content);
121
+ }
122
+ return entry.compiledTemplate(data);
123
+ }
124
+ var TEXT_FILE_EXTENSIONS = [".md", ".txt", ".yaml", ".yml", ".xml", ".toml", ".ini", ".json", ".html", ".htm"];
125
+ function isTextFilePath(value) {
126
+ var trimmed = value.trimEnd().toLowerCase();
127
+ return TEXT_FILE_EXTENSIONS.some(function (ext) { return trimmed.endsWith(ext); });
128
+ }
129
+ /**
130
+ * Resolve an InstructionsReturn value to a plain string.
131
+ * @internal Used by FlinkAgent.toAgentProps()
132
+ */
133
+ function resolveInstructionsReturn(result, ctx, agentContext) {
134
+ return __awaiter(this, void 0, void 0, function () {
135
+ var entry_1, templateData_1, file, params, entry, templateData;
136
+ return __generator(this, function (_a) {
137
+ if (typeof result === "string") {
138
+ if (isTextFilePath(result)) {
139
+ entry_1 = loadFile(result.trimEnd());
140
+ templateData_1 = { ctx: ctx, agentContext: agentContext, user: agentContext === null || agentContext === void 0 ? void 0 : agentContext.user };
141
+ return [2 /*return*/, renderTemplate(entry_1, templateData_1)];
142
+ }
143
+ return [2 /*return*/, result];
144
+ }
145
+ file = result.file, params = result.params;
146
+ entry = loadFile(file);
147
+ templateData = __assign(__assign({}, params), { ctx: ctx, agentContext: agentContext, user: agentContext === null || agentContext === void 0 ? void 0 : agentContext.user });
148
+ return [2 /*return*/, renderTemplate(entry, templateData)];
149
+ });
150
+ });
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.60",
3
+ "version": "2.0.0-alpha.62",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -166,7 +166,7 @@ describe("Agent Descendant Detection", () => {
166
166
  export default class MyAgent extends InMemoryConversationAgent<any> {
167
167
  id = "my-agent";
168
168
  description = "Test agent";
169
- instructions = "You are helpful";
169
+ instructions() { return "You are helpful"; }
170
170
  }
171
171
  `;
172
172
  testAgentDetection(source, true);
@@ -182,7 +182,7 @@ describe("Agent Descendant Detection", () => {
182
182
  export default class MyAgent extends InMemoryConversationAgent<AppContext> {
183
183
  id = "my-agent";
184
184
  description = "Test agent";
185
- instructions = "You are helpful";
185
+ instructions() { return "You are helpful"; }
186
186
  }
187
187
  `;
188
188
  testAgentDetection(source, true);
@@ -158,7 +158,7 @@ describe("AgentRunner", () => {
158
158
  input: { city: "Stockholm" },
159
159
  ctx: mockCtx,
160
160
  user: undefined,
161
- permissions: [],
161
+ permissions: undefined,
162
162
  conversationCtx: undefined,
163
163
  });
164
164
  });
@@ -34,7 +34,7 @@ describe("Conversation Hooks", () => {
34
34
  class TestAgent extends FlinkAgent<FlinkContext> {
35
35
  id = "test-agent";
36
36
  description = "Test agent";
37
- instructions = "Test instructions";
37
+ instructions() { return "Test instructions"; }
38
38
  tools: string[] = [];
39
39
 
40
40
  protected beforeRun = beforeRunSpy;
@@ -71,7 +71,7 @@ describe("Conversation Hooks", () => {
71
71
  class TestAgent extends FlinkAgent<FlinkContext> {
72
72
  id = "test-agent";
73
73
  description = "Test agent";
74
- instructions = "Test instructions";
74
+ instructions() { return "Test instructions"; }
75
75
  tools: string[] = [];
76
76
 
77
77
  protected async beforeRun(input: AgentExecuteInput, context: AgentExecuteContext) {
@@ -139,7 +139,7 @@ describe("Conversation Hooks", () => {
139
139
  class TestAgent extends FlinkAgent<FlinkContext> {
140
140
  id = "test-agent";
141
141
  description = "Test agent";
142
- instructions = "Test instructions";
142
+ instructions() { return "Test instructions"; }
143
143
  tools: string[] = ["test_tool"];
144
144
 
145
145
  protected onStep = onStepSpy;
@@ -177,7 +177,7 @@ describe("Conversation Hooks", () => {
177
177
  class TestAgent extends FlinkAgent<FlinkContext> {
178
178
  id = "test-agent";
179
179
  description = "Test agent";
180
- instructions = "Test instructions";
180
+ instructions() { return "Test instructions"; }
181
181
  tools: string[] = [];
182
182
 
183
183
  protected afterRun = afterRunSpy;
@@ -216,7 +216,7 @@ describe("Conversation Hooks", () => {
216
216
  class TestAgent extends FlinkAgent<FlinkContext> {
217
217
  id = "test-agent";
218
218
  description = "Test agent";
219
- instructions = "Test instructions";
219
+ instructions() { return "Test instructions"; }
220
220
  tools: string[] = [];
221
221
 
222
222
  protected beforeRun = beforeRunSpy;
@@ -31,7 +31,7 @@ describe("FlinkAgent", () => {
31
31
  class TestAgent extends FlinkAgent<FlinkContext> {
32
32
  id = "test-agent";
33
33
  description = "Test agent";
34
- instructions = "Test instructions";
34
+ instructions() { return "Test instructions"; }
35
35
  tools: string[] = [];
36
36
 
37
37
  async query(message: string) {
@@ -131,7 +131,7 @@ describe("FlinkAgent", () => {
131
131
  class TestAgent extends FlinkAgent<FlinkContext> {
132
132
  id = "test-agent";
133
133
  description = "Test agent";
134
- instructions = "Test instructions";
134
+ instructions() { return "Test instructions"; }
135
135
  tools: string[] = ["public_tool", "admin_tool"];
136
136
 
137
137
  async query(message: string) {
@@ -250,7 +250,7 @@ describe("FlinkAgent", () => {
250
250
  class TestAgentWithToolRef extends FlinkAgent<FlinkContext> {
251
251
  id = "test-agent";
252
252
  description = "Test agent";
253
- instructions = "Test instructions";
253
+ instructions() { return "Test instructions"; }
254
254
  tools = [mockToolFile]; // Direct tool file reference!
255
255
 
256
256
  async query(message: string) {
@@ -290,7 +290,7 @@ describe("FlinkAgent", () => {
290
290
  class MixedToolsAgent extends FlinkAgent<FlinkContext> {
291
291
  id = "test-agent";
292
292
  description = "Test agent";
293
- instructions = "Test instructions";
293
+ instructions() { return "Test instructions"; }
294
294
  tools = [
295
295
  mockToolFile, // File reference
296
296
  "tool-by-string", // String ID
@@ -317,7 +317,7 @@ describe("FlinkAgent", () => {
317
317
  class StaticAgent extends FlinkAgent<FlinkContext> {
318
318
  id = "test-agent";
319
319
  description = "Static agent";
320
- instructions = "Static instructions";
320
+ instructions() { return "Static instructions"; }
321
321
  tools: string[] = [];
322
322
 
323
323
  async query(message: string) {
@@ -342,9 +342,9 @@ describe("FlinkAgent", () => {
342
342
  class DynamicAgent extends FlinkAgent<FlinkContext> {
343
343
  id = "test-agent";
344
344
  description = "Dynamic agent";
345
- instructions = (ctx: FlinkContext, agentContext: any) => {
345
+ instructions(ctx: FlinkContext, agentContext: any) {
346
346
  return `You are a support agent for user ${agentContext.user?.name || "unknown"}`;
347
- };
347
+ }
348
348
  tools: string[] = [];
349
349
 
350
350
  async query(message: string) {
@@ -380,13 +380,13 @@ describe("FlinkAgent", () => {
380
380
  class DatabaseAgent extends FlinkAgent<any> {
381
381
  id = "test-agent";
382
382
  description = "Database agent";
383
- instructions = async (ctx: any, agentContext: any) => {
383
+ async instructions(ctx: any, agentContext: any) {
384
384
  if (!agentContext.user) {
385
385
  return "You are a support agent.";
386
386
  }
387
387
  const profile = await ctx.repos.userRepo.getById(agentContext.user.id);
388
388
  return `You are a support agent. Customer: ${profile.name}, Tier: ${profile.tier}`;
389
- };
389
+ }
390
390
  tools: string[] = [];
391
391
 
392
392
  async query(message: string) {
@@ -415,10 +415,10 @@ describe("FlinkAgent", () => {
415
415
  class CachedAgent extends FlinkAgent<FlinkContext> {
416
416
  id = "test-agent";
417
417
  description = "Cached agent";
418
- instructions = (ctx: FlinkContext, agentContext: any) => {
418
+ instructions(ctx: FlinkContext, agentContext: any) {
419
419
  callCount++;
420
420
  return `Instructions for user ${agentContext.user?.name || "unknown"}`;
421
- };
421
+ }
422
422
  tools: string[] = [];
423
423
 
424
424
  async query(message: string) {
@@ -461,10 +461,10 @@ describe("FlinkAgent", () => {
461
461
  class ConversationAgent extends FlinkAgent<FlinkContext> {
462
462
  id = "test-agent";
463
463
  description = "Conversation agent";
464
- instructions = (ctx: FlinkContext, agentContext: any) => {
464
+ instructions(ctx: FlinkContext, agentContext: any) {
465
465
  capturedContext = agentContext;
466
466
  return `Conversation: ${agentContext.conversationId || "new"}`;
467
- };
467
+ }
468
468
  tools: string[] = [];
469
469
 
470
470
  async query(message: string, conversationId?: string) {
@@ -489,9 +489,9 @@ describe("FlinkAgent", () => {
489
489
  class ErrorAgent extends FlinkAgent<FlinkContext> {
490
490
  id = "test-agent";
491
491
  description = "Error agent";
492
- instructions = () => {
492
+ instructions(): string {
493
493
  throw new Error("Database unavailable");
494
- };
494
+ }
495
495
  tools: string[] = [];
496
496
 
497
497
  async query(message: string) {
@@ -518,7 +518,7 @@ describe("FlinkAgent", () => {
518
518
  class TestAgent extends FlinkAgent<FlinkContext, TestConversationContext> {
519
519
  id = "test-agent";
520
520
  description = "Test agent";
521
- instructions = "Test instructions";
521
+ instructions() { return "Test instructions"; }
522
522
  tools: string[] = [];
523
523
 
524
524
  async query(message: string) {
@@ -15,7 +15,7 @@ describe("Streaming Integration", () => {
15
15
  class StreamingTestAgent extends FlinkAgent<FlinkContext> {
16
16
  id = "streaming-test-agent";
17
17
  description = "Test agent for streaming";
18
- instructions = "You are a test agent";
18
+ instructions() { return "You are a test agent"; }
19
19
  tools = [];
20
20
 
21
21
  async query(message: string) {
@@ -7,7 +7,7 @@ import { LLMAdapter, LLMMessage } from "../../src/ai/LLMAdapter";
7
7
  class CompactingAgent extends FlinkAgent<FlinkContext> {
8
8
  id = "compacting-agent";
9
9
  description = "Test agent with context compaction";
10
- instructions = "You are a test assistant";
10
+ instructions() { return "You are a test assistant"; }
11
11
 
12
12
  setCallbacks(
13
13
  shouldCompact?: (messages: LLMMessage[], step: number) => boolean | Promise<boolean>,
@@ -9,7 +9,7 @@ import {
9
9
  class MockConversationAgent extends ConversationAgent<FlinkContext> {
10
10
  id = "mock-agent";
11
11
  description = "Mock agent for testing";
12
- instructions = "You are a test agent";
12
+ instructions() { return "You are a test agent"; }
13
13
  tools = [];
14
14
 
15
15
  // Spy methods
@@ -3,7 +3,7 @@ import { InMemoryConversationAgent, FlinkContext } from "../../src";
3
3
  class TestAgent extends InMemoryConversationAgent<FlinkContext> {
4
4
  id = "test-agent";
5
5
  description = "Test agent";
6
- instructions = "You are a test agent";
6
+ instructions() { return "You are a test agent"; }
7
7
  tools = [];
8
8
  }
9
9
 
@@ -8,15 +8,17 @@ export default class TestAgent extends FlinkAgent<FlinkContext> {
8
8
  // Load instructions from file using project-root path
9
9
  // Note: For relative paths (./) to work, they must be called from the agent file itself
10
10
  // Since this is loaded via require() from test, we use project-root path
11
- instructions = agentInstructions(
12
- "spec/fixtures/agent-instructions/template.md",
13
- (ctx, agentContext) => ({
14
- tier: agentContext.user?.tier || "basic",
15
- isPremium: agentContext.user?.tier === "premium",
16
- isBusinessHours: true,
17
- tools: ["test-tool-1", "test-tool-2"],
18
- })
19
- );
11
+ instructions(ctx: FlinkContext, agentContext: any) {
12
+ return agentInstructions(
13
+ "spec/fixtures/agent-instructions/template.md",
14
+ (ctx, agentContext) => ({
15
+ tier: agentContext.user?.tier || "basic",
16
+ isPremium: agentContext.user?.tier === "premium",
17
+ isBusinessHours: true,
18
+ tools: ["test-tool-1", "test-tool-2"],
19
+ })
20
+ )(ctx, agentContext);
21
+ }
20
22
 
21
23
  tools = [];
22
24
  }
package/src/FlinkApp.ts CHANGED
@@ -1270,11 +1270,15 @@ export class FlinkApp<C extends FlinkContext> {
1270
1270
  log.warn(`Tool ${toolFile.__file} references outputSchema "${metadata.outputSchemaName}" but not found in schema universe`);
1271
1271
  }
1272
1272
 
1273
+ // Pass full schema universe so AJV can resolve $ref across schemas
1274
+ const allSchemas = schemaManifest.version === "2.0" ? schemaManifest.schemas : schemaManifest.definitions;
1275
+
1273
1276
  const toolExecutor = new ToolExecutor(
1274
1277
  toolFile.Tool,
1275
1278
  toolFile.default,
1276
1279
  this.ctx,
1277
- schemas // Auto-generated schemas from manifest (resolved from definitions)
1280
+ schemas, // Auto-generated schemas from manifest (resolved from definitions)
1281
+ allSchemas
1278
1282
  );
1279
1283
  this.tools[toolInstanceName] = toolExecutor;
1280
1284
 
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
+ }