@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 +19 -0
- package/dist/src/FlinkApp.js +4 -3
- package/dist/src/FlinkRepo.d.ts +1 -1
- package/dist/src/ai/FlinkAgent.d.ts +45 -1
- package/dist/src/ai/FlinkAgent.js +24 -6
- package/dist/src/ai/ToolExecutor.d.ts +3 -1
- package/dist/src/ai/ToolExecutor.js +46 -60
- package/dist/src/ai/agentInstructions.d.ts +1 -1
- package/dist/src/ai/instructionFileLoader.d.ts +44 -0
- package/dist/src/ai/instructionFileLoader.js +151 -0
- package/package.json +1 -1
- package/spec/AgentDescendantDetection.spec.ts +2 -2
- package/spec/AgentRunner.spec.ts +1 -1
- package/spec/ConversationHooks.spec.ts +5 -5
- package/spec/FlinkAgent.spec.ts +16 -16
- package/spec/StreamingIntegration.spec.ts +1 -1
- package/spec/ai/ContextCompaction.spec.ts +1 -1
- package/spec/ai/ConversationAgent.spec.ts +1 -1
- package/spec/ai/InMemoryConversationAgent.spec.ts +1 -1
- package/spec/fixtures/agent-instructions/TestAgent.ts +11 -9
- package/src/FlinkApp.ts +5 -1
- package/src/FlinkRepo.ts +1 -1
- package/src/ai/FlinkAgent.ts +56 -5
- package/src/ai/ToolExecutor.ts +39 -50
- package/src/ai/agentInstructions.ts +1 -1
- package/src/ai/instructionFileLoader.ts +126 -0
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
|
package/dist/src/FlinkApp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/src/FlinkRepo.d.ts
CHANGED
|
@@ -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():
|
|
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
|
-
|
|
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 = (
|
|
347
|
-
var conversationContext = (
|
|
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:
|
|
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,
|
|
84
|
-
var _a, _b, _c
|
|
85
|
-
return __generator(this, function (
|
|
86
|
-
switch (
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
107
|
-
|
|
108
|
-
valid = validate(input);
|
|
133
|
+
else if (this.compiledInputValidator) {
|
|
134
|
+
valid = this.compiledInputValidator(input);
|
|
109
135
|
if (!valid) {
|
|
110
|
-
errorDetails = this.formatAjvErrors(
|
|
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
|
-
|
|
162
|
+
_d.label = 3;
|
|
151
163
|
case 3:
|
|
152
|
-
|
|
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 =
|
|
174
|
+
result = _d.sent();
|
|
163
175
|
return [3 /*break*/, 6];
|
|
164
176
|
case 5:
|
|
165
|
-
err_1 =
|
|
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.
|
|
195
|
-
// Priority 2: Use
|
|
206
|
+
else if (this.compiledOutputValidator) {
|
|
207
|
+
// Priority 2 & 3: Use pre-compiled JSON Schema validator (manual or auto-generated)
|
|
196
208
|
try {
|
|
197
|
-
|
|
198
|
-
valid = validate(result.data);
|
|
209
|
+
valid = this.compiledOutputValidator(result.data);
|
|
199
210
|
if (!valid) {
|
|
200
|
-
errorDetails = this.formatAjvErrors(
|
|
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
|
@@ -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
|
|
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
|
|
185
|
+
instructions() { return "You are helpful"; }
|
|
186
186
|
}
|
|
187
187
|
`;
|
|
188
188
|
testAgentDetection(source, true);
|
package/spec/AgentRunner.spec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
219
|
+
instructions() { return "Test instructions"; }
|
|
220
220
|
tools: string[] = [];
|
|
221
221
|
|
|
222
222
|
protected beforeRun = beforeRunSpy;
|
package/spec/FlinkAgent.spec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
package/src/ai/FlinkAgent.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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,
|
package/src/ai/ToolExecutor.ts
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
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.
|
|
78
|
-
// Priority 2: Use
|
|
79
|
-
const
|
|
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(
|
|
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.
|
|
165
|
-
// Priority 2: Use
|
|
177
|
+
} else if (this.compiledOutputValidator) {
|
|
178
|
+
// Priority 2 & 3: Use pre-compiled JSON Schema validator (manual or auto-generated)
|
|
166
179
|
try {
|
|
167
|
-
const
|
|
168
|
-
const valid = validate(result.data);
|
|
180
|
+
const valid = this.compiledOutputValidator(result.data);
|
|
169
181
|
if (!valid) {
|
|
170
|
-
const errorDetails = this.formatAjvErrors(
|
|
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
|
+
}
|