@flink-app/flink 2.0.0-alpha.61 → 2.0.0-alpha.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/src/FlinkApp.js +67 -30
- 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/FlinkApp.undefinedResponse.spec.ts +123 -0
- package/spec/FlinkJob.spec.ts +95 -0
- 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 +55 -27
- 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/src/FlinkApp.ts
CHANGED
|
@@ -790,20 +790,32 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
790
790
|
}
|
|
791
791
|
|
|
792
792
|
if (validateRes && !isError(handlerRes)) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
793
|
+
if (handlerRes.data === undefined) {
|
|
794
|
+
if (handlerRes.status !== 204) {
|
|
795
|
+
const detail =
|
|
796
|
+
"Response schema is defined but handler returned no data";
|
|
797
|
+
log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response - ${detail}`);
|
|
798
|
+
return res.status(500).json({
|
|
799
|
+
status: 500,
|
|
800
|
+
error: { id: v4(), title: "Bad response", detail },
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
} else {
|
|
804
|
+
const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
|
|
805
|
+
|
|
806
|
+
if (!valid) {
|
|
807
|
+
const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
|
|
808
|
+
log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
|
|
809
|
+
|
|
810
|
+
return res.status(500).json({
|
|
811
|
+
status: 500,
|
|
812
|
+
error: {
|
|
813
|
+
id: v4(),
|
|
814
|
+
title: "Bad response",
|
|
815
|
+
detail: formattedErrors,
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
}
|
|
807
819
|
}
|
|
808
820
|
}
|
|
809
821
|
|
|
@@ -1198,18 +1210,30 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
1198
1210
|
|
|
1199
1211
|
this.scheduler.addSimpleIntervalJob(job);
|
|
1200
1212
|
} else if (jobProps.afterDelay !== undefined) {
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
+
const delayMs = ms(jobProps.afterDelay);
|
|
1214
|
+
if (delayMs === 0) {
|
|
1215
|
+
setImmediate(async () => {
|
|
1216
|
+
try {
|
|
1217
|
+
await jobFn({ ctx: this.ctx });
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
log.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
|
|
1220
|
+
console.error(err);
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
} else {
|
|
1224
|
+
const job = new SimpleIntervalJob(
|
|
1225
|
+
{
|
|
1226
|
+
milliseconds: delayMs,
|
|
1227
|
+
runImmediately: false,
|
|
1228
|
+
},
|
|
1229
|
+
task,
|
|
1230
|
+
{
|
|
1231
|
+
id: jobProps.id,
|
|
1232
|
+
preventOverrun: jobProps.singleton,
|
|
1233
|
+
}
|
|
1234
|
+
);
|
|
1235
|
+
this.scheduler.addSimpleIntervalJob(job);
|
|
1236
|
+
}
|
|
1213
1237
|
} else {
|
|
1214
1238
|
log.error(`Cannot register job ${jobProps.id} - no cron, interval or once set in ${__file}`);
|
|
1215
1239
|
continue;
|
|
@@ -1270,11 +1294,15 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
1270
1294
|
log.warn(`Tool ${toolFile.__file} references outputSchema "${metadata.outputSchemaName}" but not found in schema universe`);
|
|
1271
1295
|
}
|
|
1272
1296
|
|
|
1297
|
+
// Pass full schema universe so AJV can resolve $ref across schemas
|
|
1298
|
+
const allSchemas = schemaManifest.version === "2.0" ? schemaManifest.schemas : schemaManifest.definitions;
|
|
1299
|
+
|
|
1273
1300
|
const toolExecutor = new ToolExecutor(
|
|
1274
1301
|
toolFile.Tool,
|
|
1275
1302
|
toolFile.default,
|
|
1276
1303
|
this.ctx,
|
|
1277
|
-
schemas // Auto-generated schemas from manifest (resolved from definitions)
|
|
1304
|
+
schemas, // Auto-generated schemas from manifest (resolved from definitions)
|
|
1305
|
+
allSchemas
|
|
1278
1306
|
);
|
|
1279
1307
|
this.tools[toolInstanceName] = toolExecutor;
|
|
1280
1308
|
|
package/src/FlinkRepo.ts
CHANGED
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
|
+
}
|