@flink-app/flink 2.0.0-alpha.65 → 2.0.0-alpha.67

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,27 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 2.0.0-alpha.67
4
+
5
+ ### Patch Changes
6
+
7
+ - 418cb54: feat(instructions): add `{{import "..."}}` directive for composable markdown instruction files
8
+
9
+ Markdown instruction files can now import other markdown files using `{{import "./path/to/file.md"}}`. Imports resolve relative to the containing file, support recursive chaining, and detect circular imports with a clear error message. Handlebars template variables continue to work normally across all imported content.
10
+
11
+ - 70d574c: refactor(agent): remove debug flag in favour of log levels
12
+
13
+ The `debug` property on `FlinkAgent` and `FlinkAgentProps` is removed. Verbose agent logging (LLM requests, responses, tool calls, compaction) is now always emitted at the `debug` log level and can be enabled via the standard Flink log level configuration (e.g. `LOG_LEVEL=debug` or per-logger overrides). This removes a redundant knob that duplicated what log levels already provide.
14
+
15
+ ## 2.0.0-alpha.66
16
+
17
+ ### Patch Changes
18
+
19
+ - 5593d26: fix(tools): resolve cross-schema \$ref into \$defs for LLM providers
20
+
21
+ Tools using auto-generated schemas with TypeScript type references (e.g. a shared Canvas.ElementInput type) produced schemas with \$ref values like `"Canvas.ElementInput"`. AJV handled these fine via its schema registry, but OpenAI rejects them with "reference can only point to definitions defined at the top level of the schema".
22
+
23
+ ToolExecutor now resolves all cross-schema refs into a self-contained \$defs block, rewriting \$ref values to the standard #/\$defs/... format before returning the schema to LLM adapters. The openai-adapter sanitizer also now recurses into \$defs entries.
24
+
3
25
  ## 2.0.0-alpha.65
4
26
 
5
27
  ## 2.0.0-alpha.64
package/cli/dev.ts CHANGED
@@ -91,9 +91,9 @@ module.exports = async function dev(args: string[]) {
91
91
  try {
92
92
  await compile({ dir, entry, typeCheck: typecheck });
93
93
 
94
- // Restart server
94
+ // Restart server — wait for old process to fully exit before binding port again
95
95
  if (serverProcess) {
96
- serverProcess.kill();
96
+ await killAndWait(serverProcess);
97
97
  serverProcess = null;
98
98
  }
99
99
  serverProcess = startServer(dir);
@@ -135,6 +135,23 @@ module.exports = async function dev(args: string[]) {
135
135
  });
136
136
  };
137
137
 
138
+ function killAndWait(proc: ChildProcess): Promise<void> {
139
+ return new Promise((resolve) => {
140
+ if (proc.exitCode !== null) {
141
+ // Already exited
142
+ resolve();
143
+ return;
144
+ }
145
+ proc.once("exit", () => resolve());
146
+ proc.kill();
147
+ // Safety timeout: if the process doesn't exit within 5s, force-kill it
148
+ const timeout = setTimeout(() => {
149
+ proc.kill("SIGKILL");
150
+ }, 5000);
151
+ proc.once("exit", () => clearTimeout(timeout));
152
+ });
153
+ }
154
+
138
155
  function startServer(dir: string): ChildProcess {
139
156
  console.log("🚀 Starting server...");
140
157
 
package/dist/cli/dev.js CHANGED
@@ -123,24 +123,26 @@ module.exports = function dev(args) {
123
123
  rebuildStart = Date.now();
124
124
  _a.label = 1;
125
125
  case 1:
126
- _a.trys.push([1, 3, , 4]);
126
+ _a.trys.push([1, 5, , 6]);
127
127
  return [4 /*yield*/, (0, cli_utils_1.compile)({ dir: dir, entry: entry, typeCheck: typecheck })];
128
128
  case 2:
129
129
  _a.sent();
130
- // Restart server
131
- if (serverProcess) {
132
- serverProcess.kill();
133
- serverProcess = null;
134
- }
130
+ if (!serverProcess) return [3 /*break*/, 4];
131
+ return [4 /*yield*/, killAndWait(serverProcess)];
132
+ case 3:
133
+ _a.sent();
134
+ serverProcess = null;
135
+ _a.label = 4;
136
+ case 4:
135
137
  serverProcess = startServer(dir);
136
138
  rebuildTime = Date.now() - rebuildStart;
137
139
  logger.info("\u2705 Rebuild complete in ".concat(rebuildTime, "ms\n"));
138
- return [3 /*break*/, 4];
139
- case 3:
140
+ return [3 /*break*/, 6];
141
+ case 5:
140
142
  error_1 = _a.sent();
141
143
  logger.error("\u274C Rebuild failed: ".concat(error_1.message));
142
- return [3 /*break*/, 4];
143
- case 4:
144
+ return [3 /*break*/, 6];
145
+ case 6:
144
146
  isRebuilding = false;
145
147
  // If another change happened during rebuild, trigger again
146
148
  if (pendingRebuild) {
@@ -175,6 +177,22 @@ module.exports = function dev(args) {
175
177
  });
176
178
  });
177
179
  };
180
+ function killAndWait(proc) {
181
+ return new Promise(function (resolve) {
182
+ if (proc.exitCode !== null) {
183
+ // Already exited
184
+ resolve();
185
+ return;
186
+ }
187
+ proc.once("exit", function () { return resolve(); });
188
+ proc.kill();
189
+ // Safety timeout: if the process doesn't exit within 5s, force-kill it
190
+ var timeout = setTimeout(function () {
191
+ proc.kill("SIGKILL");
192
+ }, 5000);
193
+ proc.once("exit", function () { return clearTimeout(timeout); });
194
+ });
195
+ }
178
196
  function startServer(dir) {
179
197
  console.log("🚀 Starting server...");
180
198
  var fork = require("child_process").fork;
@@ -196,18 +196,15 @@ var AgentRunner = /** @class */ (function () {
196
196
  messages = compactedMessages;
197
197
  // Log compaction for debugging
198
198
  FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Step ").concat(step, ": Compacted ").concat(beforeCount, " messages \u2192 ").concat(messages.length));
199
- // Debug logging: Show compacted messages if debug enabled
200
- if (this.agentProps.debug) {
201
- FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Compacted messages:"), {
202
- messageCount: messages.length,
203
- messages: messages.map(function (m) { return ({
204
- role: m.role,
205
- contentPreview: typeof m.content === "string"
206
- ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
207
- : "".concat(m.content.length, " blocks"),
208
- }); }),
209
- });
210
- }
199
+ FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Compacted messages:"), {
200
+ messageCount: messages.length,
201
+ messages: messages.map(function (m) { return ({
202
+ role: m.role,
203
+ contentPreview: typeof m.content === "string"
204
+ ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
205
+ : "".concat(m.content.length, " blocks"),
206
+ }); }),
207
+ });
211
208
  _o.label = 14;
212
209
  case 14: return [3 /*break*/, 16];
213
210
  case 15:
@@ -216,24 +213,20 @@ var AgentRunner = /** @class */ (function () {
216
213
  FlinkLog_1.log.error("[Agent:".concat(this.agentName, "] Context compaction failed:"), error_1.message);
217
214
  return [3 /*break*/, 16];
218
215
  case 16:
219
- // Debug logging: Show what we're sending to the LLM
220
- if (this.agentProps.debug) {
221
- FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Step ").concat(step, "/").concat(maxSteps, " - Calling LLM with:"), {
222
- instructions: resolvedInstructions,
223
- instructionsType: typeof this.agentProps.instructions === "function" ? "dynamic-callback" : "static",
224
- messageCount: messages.length,
225
- messages: messages.map(function (m) { return ({
226
- role: m.role,
227
- contentPreview: typeof m.content === "string"
228
- ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
229
- : "".concat(m.content.length, " blocks"),
230
- }); }),
231
- toolCount: availableTools.length,
232
- tools: availableTools.map(function (t) { return t.name; }),
233
- maxTokens: this.maxTokens,
234
- temperature: this.temperature,
235
- });
236
- }
216
+ FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Step ").concat(step, "/").concat(maxSteps, " - Calling LLM with:"), {
217
+ instructionsType: typeof this.agentProps.instructions === "function" ? "dynamic-callback" : "static",
218
+ messageCount: messages.length,
219
+ messages: messages.map(function (m) { return ({
220
+ role: m.role,
221
+ contentPreview: typeof m.content === "string"
222
+ ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
223
+ : "".concat(m.content.length, " blocks"),
224
+ }); }),
225
+ toolCount: availableTools.length,
226
+ tools: availableTools.map(function (t) { return t.name; }),
227
+ maxTokens: this.maxTokens,
228
+ temperature: this.temperature,
229
+ });
237
230
  llmStream = this.llmAdapter.stream({
238
231
  instructions: resolvedInstructions,
239
232
  messages: messages,
@@ -322,21 +315,18 @@ var AgentRunner = /** @class */ (function () {
322
315
  usage: usage,
323
316
  stopReason: stopReason,
324
317
  };
325
- // Debug logging: Show what the LLM responded with
326
- if (this.agentProps.debug) {
327
- FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Step ").concat(step, " - LLM Response:"), {
328
- textLength: ((_l = llmResponse.textContent) === null || _l === void 0 ? void 0 : _l.length) || 0,
329
- textPreview: ((_m = llmResponse.textContent) === null || _m === void 0 ? void 0 : _m.substring(0, 200)) + (llmResponse.textContent && llmResponse.textContent.length > 200 ? "..." : ""),
330
- toolCallsCount: llmResponse.toolCalls.length,
331
- toolCalls: llmResponse.toolCalls.map(function (tc) { return ({
332
- name: tc.name,
333
- inputKeys: Object.keys(tc.input),
334
- input: tc.input,
335
- }); }),
336
- stopReason: llmResponse.stopReason,
337
- usage: llmResponse.usage,
338
- });
339
- }
318
+ FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Step ").concat(step, " - LLM Response:"), {
319
+ textLength: ((_l = llmResponse.textContent) === null || _l === void 0 ? void 0 : _l.length) || 0,
320
+ textPreview: ((_m = llmResponse.textContent) === null || _m === void 0 ? void 0 : _m.substring(0, 200)) + (llmResponse.textContent && llmResponse.textContent.length > 200 ? "..." : ""),
321
+ toolCallsCount: llmResponse.toolCalls.length,
322
+ toolCalls: llmResponse.toolCalls.map(function (tc) { return ({
323
+ name: tc.name,
324
+ inputKeys: Object.keys(tc.input),
325
+ input: tc.input,
326
+ }); }),
327
+ stopReason: llmResponse.stopReason,
328
+ usage: llmResponse.usage,
329
+ });
340
330
  // Extract text response
341
331
  if (llmResponse.textContent) {
342
332
  finalMessage = llmResponse.textContent;
@@ -379,13 +369,10 @@ var AgentRunner = /** @class */ (function () {
379
369
  toolExecutor = this.tools.get(toolCall.name);
380
370
  toolOutput = void 0;
381
371
  toolError = void 0;
382
- // Debug logging: Tool execution start
383
- if (this.agentProps.debug) {
384
- FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Executing tool '").concat(toolCall.name, "':"), {
385
- input: toolCall.input,
386
- inputSize: JSON.stringify(toolCall.input).length,
387
- });
388
- }
372
+ FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Executing tool '").concat(toolCall.name, "':"), {
373
+ input: toolCall.input,
374
+ inputSize: JSON.stringify(toolCall.input).length,
375
+ });
389
376
  _o.label = 40;
390
377
  case 40:
391
378
  _o.trys.push([40, 46, , 49]);
@@ -442,17 +429,14 @@ var AgentRunner = /** @class */ (function () {
442
429
  output: toolResult.success ? toolResult.data : null,
443
430
  error: toolResult.success ? undefined : toolResult.error,
444
431
  });
445
- // Debug logging: Tool execution result
446
- if (this.agentProps.debug) {
447
- FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Tool '").concat(toolCall.name, "' ").concat(toolResult.success ? "succeeded" : "failed", ":"), {
448
- success: toolResult.success,
449
- outputSize: toolResult.success ? JSON.stringify(toolResult.data).length : 0,
450
- outputPreview: toolResult.success
451
- ? JSON.stringify(toolResult.data).substring(0, 200) + (JSON.stringify(toolResult.data).length > 200 ? "..." : "")
452
- : toolResult.error,
453
- code: toolResult.code,
454
- });
455
- }
432
+ FlinkLog_1.log.debug("[Agent:".concat(this.agentName, "] Tool '").concat(toolCall.name, "' ").concat(toolResult.success ? "succeeded" : "failed", ":"), {
433
+ success: toolResult.success,
434
+ outputSize: toolResult.success ? JSON.stringify(toolResult.data).length : 0,
435
+ outputPreview: toolResult.success
436
+ ? JSON.stringify(toolResult.data).substring(0, 200) + (JSON.stringify(toolResult.data).length > 200 ? "..." : "")
437
+ : toolResult.error,
438
+ code: toolResult.code,
439
+ });
456
440
  if (!toolResult.success) {
457
441
  FlinkLog_1.log.warn("Tool ".concat(toolCall.name, " returned error:"), toolResult.error);
458
442
  }
@@ -116,17 +116,6 @@ export interface FlinkAgentProps<Ctx extends FlinkContext> {
116
116
  timeoutMs?: number;
117
117
  };
118
118
  permissions?: string | string[] | ((user?: any) => boolean);
119
- /**
120
- * Enable verbose debug logging for this agent
121
- * When true, logs detailed information about:
122
- * - Full LLM requests (messages, tools, parameters)
123
- * - Full LLM responses (text, tool calls)
124
- * - Tool execution details (input, output, errors)
125
- * - Conversation state changes
126
- *
127
- * Useful for debugging tool calling issues and understanding agent behavior
128
- */
129
- debug?: boolean;
130
119
  /**
131
120
  * Callback to determine if history compaction is needed
132
121
  * Called before each LLM call in the agentic loop
@@ -297,7 +286,6 @@ export declare abstract class FlinkAgent<Ctx extends FlinkContext, ConversationC
297
286
  timeoutMs?: number;
298
287
  };
299
288
  permissions?: string | string[] | ((user?: any) => boolean);
300
- debug?: boolean;
301
289
  protected shouldCompact?: ShouldCompactCallback;
302
290
  protected compactHistory?: CompactHistoryCallback;
303
291
  /**
@@ -647,7 +647,6 @@ var FlinkAgent = /** @class */ (function () {
647
647
  model: this.model,
648
648
  limits: this.limits,
649
649
  permissions: this.permissions,
650
- debug: this.debug,
651
650
  // Pass context compaction callbacks
652
651
  shouldCompact: (_a = this.shouldCompact) === null || _a === void 0 ? void 0 : _a.bind(this),
653
652
  compactHistory: (_b = this.compactHistory) === null || _b === void 0 ? void 0 : _b.bind(this),
@@ -6,6 +6,7 @@ export declare class ToolExecutor<Ctx extends FlinkContext> {
6
6
  private toolFn;
7
7
  private ctx;
8
8
  private autoSchemas?;
9
+ private allSchemas?;
9
10
  private ajv;
10
11
  private compiledInputValidator?;
11
12
  private compiledOutputValidator?;
@@ -14,7 +15,7 @@ export declare class ToolExecutor<Ctx extends FlinkContext> {
14
15
  outputSchema?: any;
15
16
  inputTypeHint?: "void" | "any" | "named";
16
17
  outputTypeHint?: "void" | "any" | "named";
17
- } | undefined, allSchemas?: Record<string, any>);
18
+ } | undefined, allSchemas?: Record<string, any> | undefined);
18
19
  /**
19
20
  * Execute the tool with input
20
21
  * @param input - Tool input data
@@ -26,6 +27,15 @@ export declare class ToolExecutor<Ctx extends FlinkContext> {
26
27
  conversationContext?: any;
27
28
  }): Promise<ToolResult<any>>;
28
29
  getToolSchema(): FlinkToolSchema;
30
+ /**
31
+ * Resolve cross-schema $ref values into $defs so the schema is self-contained.
32
+ *
33
+ * Flink's schema manifest stores schemas as separate documents with IDs like
34
+ * "Canvas.ElementInput". These are valid for AJV (which uses a schema registry),
35
+ * but LLM providers like OpenAI require a single self-contained schema where all
36
+ * $ref values point to #/$defs/... entries at the top level.
37
+ */
38
+ private resolveSchemaRefs;
29
39
  /**
30
40
  * Get tool result for AI consumption
31
41
  * Formats ToolResult into string for AI context
@@ -51,6 +51,7 @@ var ToolExecutor = /** @class */ (function () {
51
51
  this.toolFn = toolFn;
52
52
  this.ctx = ctx;
53
53
  this.autoSchemas = autoSchemas;
54
+ this.allSchemas = allSchemas;
54
55
  this.ajv = new ajv_1.default({ allErrors: true });
55
56
  // Pre-populate AJV with all schemas so $ref references resolve across schema boundaries
56
57
  if (allSchemas) {
@@ -263,7 +264,7 @@ var ToolExecutor = /** @class */ (function () {
263
264
  return {
264
265
  name: this.toolProps.id,
265
266
  description: this.toolProps.description,
266
- inputSchema: this.autoSchemas.inputSchema,
267
+ inputSchema: this.resolveSchemaRefs(this.autoSchemas.inputSchema),
267
268
  };
268
269
  }
269
270
  // No schema provided - return schema based on type hint
@@ -304,6 +305,68 @@ var ToolExecutor = /** @class */ (function () {
304
305
  };
305
306
  }
306
307
  };
308
+ /**
309
+ * Resolve cross-schema $ref values into $defs so the schema is self-contained.
310
+ *
311
+ * Flink's schema manifest stores schemas as separate documents with IDs like
312
+ * "Canvas.ElementInput". These are valid for AJV (which uses a schema registry),
313
+ * but LLM providers like OpenAI require a single self-contained schema where all
314
+ * $ref values point to #/$defs/... entries at the top level.
315
+ */
316
+ ToolExecutor.prototype.resolveSchemaRefs = function (schema) {
317
+ var _this = this;
318
+ if (!this.allSchemas)
319
+ return schema;
320
+ var defs = {};
321
+ var collectRefs = function (node, visited) {
322
+ if (!node || typeof node !== "object")
323
+ return;
324
+ if (node.$ref && typeof node.$ref === "string" && !node.$ref.startsWith("#")) {
325
+ var refId = node.$ref;
326
+ if (!visited.has(refId) && _this.allSchemas[refId]) {
327
+ visited.add(refId);
328
+ // Clone and strip top-level JSON Schema meta fields not valid inside $defs
329
+ var def = JSON.parse(JSON.stringify(_this.allSchemas[refId]));
330
+ delete def.$id;
331
+ delete def.$schema;
332
+ defs[refId] = def;
333
+ // Recurse into the referenced schema to collect its deps
334
+ collectRefs(def, visited);
335
+ }
336
+ }
337
+ for (var _i = 0, _a = Object.values(node); _i < _a.length; _i++) {
338
+ var value = _a[_i];
339
+ collectRefs(value, visited);
340
+ }
341
+ };
342
+ var visited = new Set();
343
+ collectRefs(schema, visited);
344
+ if (Object.keys(defs).length === 0)
345
+ return schema;
346
+ // Deep clone and rewrite all non-standard $ref values to #/$defs/<id>
347
+ var resolved = JSON.parse(JSON.stringify(schema));
348
+ delete resolved.$id;
349
+ delete resolved.$schema;
350
+ var rewriteRefs = function (node) {
351
+ if (!node || typeof node !== "object")
352
+ return;
353
+ if (node.$ref && typeof node.$ref === "string" && !node.$ref.startsWith("#")) {
354
+ node.$ref = "#/$defs/".concat(node.$ref);
355
+ }
356
+ for (var _i = 0, _a = Object.values(node); _i < _a.length; _i++) {
357
+ var value = _a[_i];
358
+ rewriteRefs(value);
359
+ }
360
+ };
361
+ rewriteRefs(resolved);
362
+ // Also rewrite refs inside the collected defs
363
+ for (var _i = 0, _a = Object.values(defs); _i < _a.length; _i++) {
364
+ var def = _a[_i];
365
+ rewriteRefs(def);
366
+ }
367
+ resolved.$defs = defs;
368
+ return resolved;
369
+ };
307
370
  /**
308
371
  * Get tool result for AI consumption
309
372
  * Formats ToolResult into string for AI context
@@ -78,6 +78,28 @@ var fs = __importStar(require("fs"));
78
78
  var path = __importStar(require("path"));
79
79
  var handlebars_1 = __importDefault(require("handlebars"));
80
80
  var fileCache = new Map();
81
+ var IMPORT_REGEX = /\{\{\s*import\s+["']([^"']+)['"]\s*\}\}/g;
82
+ function resolveImports(content, currentDir, visited) {
83
+ if (visited === void 0) { visited = new Set(); }
84
+ return content.replace(IMPORT_REGEX, function (match, importPath) {
85
+ var resolved = path.resolve(currentDir, importPath);
86
+ if (visited.has(resolved)) {
87
+ throw new Error("Circular import detected: ".concat(resolved));
88
+ }
89
+ var nextVisited = new Set(visited);
90
+ nextVisited.add(resolved);
91
+ try {
92
+ var importedContent = fs.readFileSync(resolved, "utf-8");
93
+ return resolveImports(importedContent, path.dirname(resolved), nextVisited);
94
+ }
95
+ catch (err) {
96
+ if (err.code === "ENOENT") {
97
+ throw new Error("Imported markdown file not found: ".concat(resolved, " (imported from: ").concat(currentDir, ")"));
98
+ }
99
+ throw err;
100
+ }
101
+ });
102
+ }
81
103
  /**
82
104
  * Resolve a file path relative to project root (process.cwd()).
83
105
  * Leading `./` and `/` are normalised away — all paths are treated as project-root-relative.
@@ -97,7 +119,8 @@ function loadFile(filePath) {
97
119
  return cached;
98
120
  }
99
121
  var content = fs.readFileSync(resolvedPath, "utf-8");
100
- var entry = { content: content, mtime: mtime };
122
+ var resolvedContent = resolveImports(content, path.dirname(resolvedPath));
123
+ var entry = { content: content, resolvedContent: resolvedContent, mtime: mtime };
101
124
  fileCache.set(resolvedPath, entry);
102
125
  return entry;
103
126
  }
@@ -110,14 +133,14 @@ function loadFile(filePath) {
110
133
  }
111
134
  function renderTemplate(entry, data) {
112
135
  if (entry.hasTemplateExpressions === false) {
113
- return entry.content;
136
+ return entry.resolvedContent;
114
137
  }
115
138
  if (!entry.compiledTemplate) {
116
- entry.hasTemplateExpressions = /\{\{/.test(entry.content);
139
+ entry.hasTemplateExpressions = /\{\{/.test(entry.resolvedContent);
117
140
  if (!entry.hasTemplateExpressions) {
118
- return entry.content;
141
+ return entry.resolvedContent;
119
142
  }
120
- entry.compiledTemplate = handlebars_1.default.compile(entry.content);
143
+ entry.compiledTemplate = handlebars_1.default.compile(entry.resolvedContent);
121
144
  }
122
145
  return entry.compiledTemplate(data);
123
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.65",
3
+ "version": "2.0.0-alpha.67",
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",
@@ -276,6 +276,119 @@ describe("ToolExecutor", () => {
276
276
  expect(schema.description).toBe("Get weather for a city");
277
277
  expect(schema.inputSchema).toBeDefined();
278
278
  });
279
+
280
+ describe("cross-schema $ref resolution", () => {
281
+ const allSchemas = {
282
+ "MyTool.Input": {
283
+ $id: "MyTool.Input",
284
+ $schema: "http://json-schema.org/draft-07/schema#",
285
+ type: "object",
286
+ properties: {
287
+ id: { type: "string" },
288
+ item: { $ref: "Shared.Item" },
289
+ },
290
+ required: ["id", "item"],
291
+ },
292
+ "Shared.Item": {
293
+ $id: "Shared.Item",
294
+ $schema: "http://json-schema.org/draft-07/schema#",
295
+ type: "object",
296
+ properties: {
297
+ name: { type: "string" },
298
+ tag: { $ref: "Shared.Tag" },
299
+ },
300
+ required: ["name"],
301
+ },
302
+ "Shared.Tag": {
303
+ $id: "Shared.Tag",
304
+ $schema: "http://json-schema.org/draft-07/schema#",
305
+ type: "string",
306
+ enum: ["foo", "bar"],
307
+ },
308
+ };
309
+
310
+ it("should resolve cross-schema $refs into $defs", () => {
311
+ const toolProps: FlinkToolProps = { id: "my-tool", description: "Test" };
312
+ const toolFn: FlinkTool<any> = async () => ({ success: true, data: {} });
313
+ const executor = new ToolExecutor(
314
+ toolProps,
315
+ toolFn,
316
+ mockCtx,
317
+ { inputSchema: allSchemas["MyTool.Input"], inputTypeHint: "named" },
318
+ allSchemas
319
+ );
320
+
321
+ const { inputSchema } = executor.getToolSchema();
322
+
323
+ // $ref values should use JSON pointer format
324
+ expect(inputSchema.properties.item.$ref).toBe("#/$defs/Shared.Item");
325
+
326
+ // All transitively referenced schemas should be in $defs
327
+ expect(inputSchema.$defs).toBeDefined();
328
+ expect(inputSchema.$defs["Shared.Item"]).toBeDefined();
329
+ expect(inputSchema.$defs["Shared.Tag"]).toBeDefined();
330
+
331
+ // $refs inside $defs should also be rewritten
332
+ expect(inputSchema.$defs["Shared.Item"].properties.tag.$ref).toBe("#/$defs/Shared.Tag");
333
+ });
334
+
335
+ it("should strip $id and $schema from inlined $defs", () => {
336
+ const toolProps: FlinkToolProps = { id: "my-tool", description: "Test" };
337
+ const toolFn: FlinkTool<any> = async () => ({ success: true, data: {} });
338
+ const executor = new ToolExecutor(
339
+ toolProps,
340
+ toolFn,
341
+ mockCtx,
342
+ { inputSchema: allSchemas["MyTool.Input"], inputTypeHint: "named" },
343
+ allSchemas
344
+ );
345
+
346
+ const { inputSchema } = executor.getToolSchema();
347
+
348
+ expect(inputSchema.$defs["Shared.Item"].$id).toBeUndefined();
349
+ expect(inputSchema.$defs["Shared.Item"].$schema).toBeUndefined();
350
+ expect(inputSchema.$defs["Shared.Tag"].$id).toBeUndefined();
351
+ expect(inputSchema.$defs["Shared.Tag"].$schema).toBeUndefined();
352
+ });
353
+
354
+ it("should strip $id and $schema from root schema", () => {
355
+ const toolProps: FlinkToolProps = { id: "my-tool", description: "Test" };
356
+ const toolFn: FlinkTool<any> = async () => ({ success: true, data: {} });
357
+ const executor = new ToolExecutor(
358
+ toolProps,
359
+ toolFn,
360
+ mockCtx,
361
+ { inputSchema: allSchemas["MyTool.Input"], inputTypeHint: "named" },
362
+ allSchemas
363
+ );
364
+
365
+ const { inputSchema } = executor.getToolSchema();
366
+
367
+ expect(inputSchema.$id).toBeUndefined();
368
+ expect(inputSchema.$schema).toBeUndefined();
369
+ });
370
+
371
+ it("should return schema unchanged when no cross-schema refs exist", () => {
372
+ const simpleSchema = {
373
+ $id: "Simple.Input",
374
+ type: "object",
375
+ properties: { name: { type: "string" } },
376
+ };
377
+ const toolProps: FlinkToolProps = { id: "simple-tool", description: "Test" };
378
+ const toolFn: FlinkTool<any> = async () => ({ success: true, data: {} });
379
+ const executor = new ToolExecutor(
380
+ toolProps,
381
+ toolFn,
382
+ mockCtx,
383
+ { inputSchema: simpleSchema, inputTypeHint: "named" },
384
+ allSchemas
385
+ );
386
+
387
+ const { inputSchema } = executor.getToolSchema();
388
+
389
+ expect(inputSchema.$defs).toBeUndefined();
390
+ });
391
+ });
279
392
  });
280
393
 
281
394
  describe("Result formatting for AI", () => {
@@ -127,19 +127,16 @@ export class AgentRunner {
127
127
  // Log compaction for debugging
128
128
  log.debug(`[Agent:${this.agentName}] Step ${step}: Compacted ${beforeCount} messages → ${messages.length}`);
129
129
 
130
- // Debug logging: Show compacted messages if debug enabled
131
- if (this.agentProps.debug) {
132
- log.debug(`[Agent:${this.agentName}] Compacted messages:`, {
133
- messageCount: messages.length,
134
- messages: messages.map((m) => ({
135
- role: m.role,
136
- contentPreview:
137
- typeof m.content === "string"
138
- ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
139
- : `${(m.content as any[]).length} blocks`,
140
- })),
141
- });
142
- }
130
+ log.debug(`[Agent:${this.agentName}] Compacted messages:`, {
131
+ messageCount: messages.length,
132
+ messages: messages.map((m) => ({
133
+ role: m.role,
134
+ contentPreview:
135
+ typeof m.content === "string"
136
+ ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
137
+ : `${(m.content as any[]).length} blocks`,
138
+ })),
139
+ });
143
140
  }
144
141
  } catch (error: any) {
145
142
  // Log error but don't fail execution - compaction is optional optimization
@@ -147,25 +144,21 @@ export class AgentRunner {
147
144
  }
148
145
  }
149
146
 
150
- // Debug logging: Show what we're sending to the LLM
151
- if (this.agentProps.debug) {
152
- log.debug(`[Agent:${this.agentName}] Step ${step}/${maxSteps} - Calling LLM with:`, {
153
- instructions: resolvedInstructions,
154
- instructionsType: typeof this.agentProps.instructions === "function" ? "dynamic-callback" : "static",
155
- messageCount: messages.length,
156
- messages: messages.map((m) => ({
157
- role: m.role,
158
- contentPreview:
159
- typeof m.content === "string"
160
- ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
161
- : `${(m.content as any[]).length} blocks`,
162
- })),
163
- toolCount: availableTools.length,
164
- tools: availableTools.map((t) => t.name),
165
- maxTokens: this.maxTokens,
166
- temperature: this.temperature,
167
- });
168
- }
147
+ log.debug(`[Agent:${this.agentName}] Step ${step}/${maxSteps} - Calling LLM with:`, {
148
+ instructionsType: typeof this.agentProps.instructions === "function" ? "dynamic-callback" : "static",
149
+ messageCount: messages.length,
150
+ messages: messages.map((m) => ({
151
+ role: m.role,
152
+ contentPreview:
153
+ typeof m.content === "string"
154
+ ? m.content.substring(0, 100) + (m.content.length > 100 ? "..." : "")
155
+ : `${(m.content as any[]).length} blocks`,
156
+ })),
157
+ toolCount: availableTools.length,
158
+ tools: availableTools.map((t) => t.name),
159
+ maxTokens: this.maxTokens,
160
+ temperature: this.temperature,
161
+ });
169
162
 
170
163
  // Call AI model via adapter using streaming
171
164
  const llmStream = this.llmAdapter.stream({
@@ -225,21 +218,18 @@ export class AgentRunner {
225
218
  stopReason,
226
219
  };
227
220
 
228
- // Debug logging: Show what the LLM responded with
229
- if (this.agentProps.debug) {
230
- log.debug(`[Agent:${this.agentName}] Step ${step} - LLM Response:`, {
231
- textLength: llmResponse.textContent?.length || 0,
232
- textPreview: llmResponse.textContent?.substring(0, 200) + (llmResponse.textContent && llmResponse.textContent.length > 200 ? "..." : ""),
233
- toolCallsCount: llmResponse.toolCalls.length,
234
- toolCalls: llmResponse.toolCalls.map((tc) => ({
235
- name: tc.name,
236
- inputKeys: Object.keys(tc.input),
237
- input: tc.input,
238
- })),
239
- stopReason: llmResponse.stopReason,
240
- usage: llmResponse.usage,
241
- });
242
- }
221
+ log.debug(`[Agent:${this.agentName}] Step ${step} - LLM Response:`, {
222
+ textLength: llmResponse.textContent?.length || 0,
223
+ textPreview: llmResponse.textContent?.substring(0, 200) + (llmResponse.textContent && llmResponse.textContent.length > 200 ? "..." : ""),
224
+ toolCallsCount: llmResponse.toolCalls.length,
225
+ toolCalls: llmResponse.toolCalls.map((tc) => ({
226
+ name: tc.name,
227
+ inputKeys: Object.keys(tc.input),
228
+ input: tc.input,
229
+ })),
230
+ stopReason: llmResponse.stopReason,
231
+ usage: llmResponse.usage,
232
+ });
243
233
 
244
234
  // Extract text response
245
235
  if (llmResponse.textContent) {
@@ -293,13 +283,10 @@ export class AgentRunner {
293
283
  let toolOutput: any;
294
284
  let toolError: string | undefined;
295
285
 
296
- // Debug logging: Tool execution start
297
- if (this.agentProps.debug) {
298
- log.debug(`[Agent:${this.agentName}] Executing tool '${toolCall.name}':`, {
299
- input: toolCall.input,
300
- inputSize: JSON.stringify(toolCall.input).length,
301
- });
302
- }
286
+ log.debug(`[Agent:${this.agentName}] Executing tool '${toolCall.name}':`, {
287
+ input: toolCall.input,
288
+ inputSize: JSON.stringify(toolCall.input).length,
289
+ });
303
290
 
304
291
  try {
305
292
  if (!toolExecutor) {
@@ -352,17 +339,14 @@ export class AgentRunner {
352
339
  error: toolResult.success ? undefined : toolResult.error,
353
340
  });
354
341
 
355
- // Debug logging: Tool execution result
356
- if (this.agentProps.debug) {
357
- log.debug(`[Agent:${this.agentName}] Tool '${toolCall.name}' ${toolResult.success ? "succeeded" : "failed"}:`, {
358
- success: toolResult.success,
359
- outputSize: toolResult.success ? JSON.stringify(toolResult.data).length : 0,
360
- outputPreview: toolResult.success
361
- ? JSON.stringify(toolResult.data).substring(0, 200) + (JSON.stringify(toolResult.data).length > 200 ? "..." : "")
362
- : toolResult.error,
363
- code: (toolResult as any).code,
364
- });
365
- }
342
+ log.debug(`[Agent:${this.agentName}] Tool '${toolCall.name}' ${toolResult.success ? "succeeded" : "failed"}:`, {
343
+ success: toolResult.success,
344
+ outputSize: toolResult.success ? JSON.stringify(toolResult.data).length : 0,
345
+ outputPreview: toolResult.success
346
+ ? JSON.stringify(toolResult.data).substring(0, 200) + (JSON.stringify(toolResult.data).length > 200 ? "..." : "")
347
+ : toolResult.error,
348
+ code: (toolResult as any).code,
349
+ });
366
350
 
367
351
  if (!toolResult.success) {
368
352
  log.warn(`Tool ${toolCall.name} returned error:`, toolResult.error);
@@ -130,17 +130,6 @@ export interface FlinkAgentProps<Ctx extends FlinkContext> {
130
130
  timeoutMs?: number; // Phase 2: Not yet implemented
131
131
  };
132
132
  permissions?: string | string[] | ((user?: any) => boolean);
133
- /**
134
- * Enable verbose debug logging for this agent
135
- * When true, logs detailed information about:
136
- * - Full LLM requests (messages, tools, parameters)
137
- * - Full LLM responses (text, tool calls)
138
- * - Tool execution details (input, output, errors)
139
- * - Conversation state changes
140
- *
141
- * Useful for debugging tool calling issues and understanding agent behavior
142
- */
143
- debug?: boolean;
144
133
 
145
134
  /**
146
135
  * Callback to determine if history compaction is needed
@@ -319,7 +308,6 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
319
308
  };
320
309
  limits?: { maxSteps?: number; timeoutMs?: number };
321
310
  permissions?: string | string[] | ((user?: any) => boolean);
322
- debug?: boolean; // Enable verbose debug logging
323
311
  protected shouldCompact?: ShouldCompactCallback;
324
312
  protected compactHistory?: CompactHistoryCallback;
325
313
 
@@ -755,7 +743,6 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
755
743
  model: this.model,
756
744
  limits: this.limits,
757
745
  permissions: this.permissions,
758
- debug: this.debug,
759
746
  // Pass context compaction callbacks
760
747
  shouldCompact: this.shouldCompact?.bind(this),
761
748
  compactHistory: this.compactHistory?.bind(this),
@@ -23,7 +23,7 @@ export class ToolExecutor<Ctx extends FlinkContext> {
23
23
  inputTypeHint?: 'void' | 'any' | 'named';
24
24
  outputTypeHint?: 'void' | 'any' | 'named';
25
25
  },
26
- allSchemas?: Record<string, any>
26
+ private allSchemas?: Record<string, any>
27
27
  ) {
28
28
  // Pre-populate AJV with all schemas so $ref references resolve across schema boundaries
29
29
  if (allSchemas) {
@@ -238,7 +238,7 @@ export class ToolExecutor<Ctx extends FlinkContext> {
238
238
  return {
239
239
  name: this.toolProps.id,
240
240
  description: this.toolProps.description,
241
- inputSchema: this.autoSchemas.inputSchema,
241
+ inputSchema: this.resolveSchemaRefs(this.autoSchemas.inputSchema),
242
242
  };
243
243
  }
244
244
 
@@ -280,6 +280,73 @@ export class ToolExecutor<Ctx extends FlinkContext> {
280
280
  }
281
281
  }
282
282
 
283
+ /**
284
+ * Resolve cross-schema $ref values into $defs so the schema is self-contained.
285
+ *
286
+ * Flink's schema manifest stores schemas as separate documents with IDs like
287
+ * "Canvas.ElementInput". These are valid for AJV (which uses a schema registry),
288
+ * but LLM providers like OpenAI require a single self-contained schema where all
289
+ * $ref values point to #/$defs/... entries at the top level.
290
+ */
291
+ private resolveSchemaRefs(schema: any): any {
292
+ if (!this.allSchemas) return schema;
293
+
294
+ const defs: Record<string, any> = {};
295
+
296
+ const collectRefs = (node: any, visited: Set<string>): void => {
297
+ if (!node || typeof node !== "object") return;
298
+
299
+ if (node.$ref && typeof node.$ref === "string" && !node.$ref.startsWith("#")) {
300
+ const refId = node.$ref;
301
+ if (!visited.has(refId) && this.allSchemas![refId]) {
302
+ visited.add(refId);
303
+ // Clone and strip top-level JSON Schema meta fields not valid inside $defs
304
+ const def = JSON.parse(JSON.stringify(this.allSchemas![refId]));
305
+ delete def.$id;
306
+ delete def.$schema;
307
+ defs[refId] = def;
308
+ // Recurse into the referenced schema to collect its deps
309
+ collectRefs(def, visited);
310
+ }
311
+ }
312
+
313
+ for (const value of Object.values(node)) {
314
+ collectRefs(value, visited);
315
+ }
316
+ };
317
+
318
+ const visited = new Set<string>();
319
+ collectRefs(schema, visited);
320
+
321
+ if (Object.keys(defs).length === 0) return schema;
322
+
323
+ // Deep clone and rewrite all non-standard $ref values to #/$defs/<id>
324
+ const resolved = JSON.parse(JSON.stringify(schema));
325
+ delete resolved.$id;
326
+ delete resolved.$schema;
327
+
328
+ const rewriteRefs = (node: any): void => {
329
+ if (!node || typeof node !== "object") return;
330
+
331
+ if (node.$ref && typeof node.$ref === "string" && !node.$ref.startsWith("#")) {
332
+ node.$ref = `#/$defs/${node.$ref}`;
333
+ }
334
+
335
+ for (const value of Object.values(node)) {
336
+ rewriteRefs(value);
337
+ }
338
+ };
339
+
340
+ rewriteRefs(resolved);
341
+ // Also rewrite refs inside the collected defs
342
+ for (const def of Object.values(defs)) {
343
+ rewriteRefs(def);
344
+ }
345
+
346
+ resolved.$defs = defs;
347
+ return resolved;
348
+ }
349
+
283
350
  /**
284
351
  * Get tool result for AI consumption
285
352
  * Formats ToolResult into string for AI context
@@ -41,6 +41,7 @@ export type InstructionsReturn = string | { file: string; params?: Record<string
41
41
 
42
42
  interface FileCacheEntry {
43
43
  content: string;
44
+ resolvedContent: string;
44
45
  mtime: number;
45
46
  compiledTemplate?: Handlebars.TemplateDelegate;
46
47
  hasTemplateExpressions?: boolean;
@@ -48,6 +49,28 @@ interface FileCacheEntry {
48
49
 
49
50
  const fileCache = new Map<string, FileCacheEntry>();
50
51
 
52
+ const IMPORT_REGEX = /\{\{\s*import\s+["']([^"']+)['"]\s*\}\}/g;
53
+
54
+ function resolveImports(content: string, currentDir: string, visited: Set<string> = new Set()): string {
55
+ return content.replace(IMPORT_REGEX, (match, importPath) => {
56
+ const resolved = path.resolve(currentDir, importPath);
57
+ if (visited.has(resolved)) {
58
+ throw new Error(`Circular import detected: ${resolved}`);
59
+ }
60
+ const nextVisited = new Set(visited);
61
+ nextVisited.add(resolved);
62
+ try {
63
+ const importedContent = fs.readFileSync(resolved, "utf-8");
64
+ return resolveImports(importedContent, path.dirname(resolved), nextVisited);
65
+ } catch (err: any) {
66
+ if (err.code === "ENOENT") {
67
+ throw new Error(`Imported markdown file not found: ${resolved} (imported from: ${currentDir})`);
68
+ }
69
+ throw err;
70
+ }
71
+ });
72
+ }
73
+
51
74
  /**
52
75
  * Resolve a file path relative to project root (process.cwd()).
53
76
  * Leading `./` and `/` are normalised away — all paths are treated as project-root-relative.
@@ -70,7 +93,8 @@ function loadFile(filePath: string): FileCacheEntry {
70
93
  }
71
94
 
72
95
  const content = fs.readFileSync(resolvedPath, "utf-8");
73
- const entry: FileCacheEntry = { content, mtime };
96
+ const resolvedContent = resolveImports(content, path.dirname(resolvedPath));
97
+ const entry: FileCacheEntry = { content, resolvedContent, mtime };
74
98
  fileCache.set(resolvedPath, entry);
75
99
  return entry;
76
100
  } catch (err: any) {
@@ -83,15 +107,15 @@ function loadFile(filePath: string): FileCacheEntry {
83
107
 
84
108
  function renderTemplate(entry: FileCacheEntry, data: any): string {
85
109
  if (entry.hasTemplateExpressions === false) {
86
- return entry.content;
110
+ return entry.resolvedContent;
87
111
  }
88
112
 
89
113
  if (!entry.compiledTemplate) {
90
- entry.hasTemplateExpressions = /\{\{/.test(entry.content);
114
+ entry.hasTemplateExpressions = /\{\{/.test(entry.resolvedContent);
91
115
  if (!entry.hasTemplateExpressions) {
92
- return entry.content;
116
+ return entry.resolvedContent;
93
117
  }
94
- entry.compiledTemplate = Handlebars.compile(entry.content);
118
+ entry.compiledTemplate = Handlebars.compile(entry.resolvedContent);
95
119
  }
96
120
 
97
121
  return entry.compiledTemplate(data);