@flink-app/flink 0.14.3 → 2.0.0-alpha.48

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/cli/build.ts +8 -1
  3. package/cli/run.ts +8 -1
  4. package/dist/cli/build.js +8 -1
  5. package/dist/cli/run.js +8 -1
  6. package/dist/src/FlinkApp.d.ts +33 -0
  7. package/dist/src/FlinkApp.js +279 -35
  8. package/dist/src/FlinkContext.d.ts +21 -0
  9. package/dist/src/FlinkHttpHandler.d.ts +152 -9
  10. package/dist/src/FlinkHttpHandler.js +37 -1
  11. package/dist/src/TypeScriptCompiler.d.ts +42 -0
  12. package/dist/src/TypeScriptCompiler.js +346 -4
  13. package/dist/src/TypeScriptUtils.js +4 -0
  14. package/dist/src/ai/AgentRunner.d.ts +39 -0
  15. package/dist/src/ai/AgentRunner.js +625 -0
  16. package/dist/src/ai/FlinkAgent.d.ts +446 -0
  17. package/dist/src/ai/FlinkAgent.js +633 -0
  18. package/dist/src/ai/FlinkTool.d.ts +37 -0
  19. package/dist/src/ai/FlinkTool.js +2 -0
  20. package/dist/src/ai/LLMAdapter.d.ts +119 -0
  21. package/dist/src/ai/LLMAdapter.js +2 -0
  22. package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
  23. package/dist/src/ai/SubAgentExecutor.js +220 -0
  24. package/dist/src/ai/ToolExecutor.d.ts +35 -0
  25. package/dist/src/ai/ToolExecutor.js +237 -0
  26. package/dist/src/ai/index.d.ts +5 -0
  27. package/dist/src/ai/index.js +21 -0
  28. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  29. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  30. package/dist/src/index.d.ts +4 -0
  31. package/dist/src/index.js +4 -0
  32. package/dist/src/utils.d.ts +30 -0
  33. package/dist/src/utils.js +52 -0
  34. package/package.json +16 -2
  35. package/readme.md +425 -0
  36. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  37. package/spec/AgentRunner.spec.ts +527 -0
  38. package/spec/ConversationHooks.spec.ts +290 -0
  39. package/spec/FlinkAgent.spec.ts +310 -0
  40. package/spec/FlinkApp.onError.spec.ts +1 -2
  41. package/spec/FlinkApp.query.spec.ts +107 -0
  42. package/spec/FlinkApp.validationMode.spec.ts +155 -0
  43. package/spec/StreamingIntegration.spec.ts +138 -0
  44. package/spec/SubAgentSupport.spec.ts +941 -0
  45. package/spec/ToolExecutor.spec.ts +360 -0
  46. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
  47. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
  48. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
  49. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
  50. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
  51. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
  52. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
  53. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
  54. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
  55. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  56. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  57. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
  58. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
  59. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
  60. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
  61. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
  62. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
  63. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
  64. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
  65. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
  66. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  67. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  68. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  69. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  70. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  71. package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
  72. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  73. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  74. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  75. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  76. package/spec/mock-project/dist/src/FlinkLog.js +26 -0
  77. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  78. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  79. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  80. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  81. package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
  82. package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
  83. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  84. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  85. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
  86. package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
  87. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  88. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  89. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  90. package/spec/mock-project/dist/src/index.js +17 -69
  91. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  92. package/spec/mock-project/dist/src/utils.js +290 -0
  93. package/spec/mock-project/tsconfig.json +6 -1
  94. package/spec/testHelpers.ts +49 -0
  95. package/spec/utils.caseConversion.spec.ts +80 -0
  96. package/spec/utils.spec.ts +13 -13
  97. package/src/FlinkApp.ts +275 -8
  98. package/src/FlinkContext.ts +22 -0
  99. package/src/FlinkHttpHandler.ts +164 -10
  100. package/src/TypeScriptCompiler.ts +398 -7
  101. package/src/TypeScriptUtils.ts +5 -0
  102. package/src/ai/AgentRunner.ts +549 -0
  103. package/src/ai/FlinkAgent.ts +770 -0
  104. package/src/ai/FlinkTool.ts +40 -0
  105. package/src/ai/LLMAdapter.ts +96 -0
  106. package/src/ai/SubAgentExecutor.ts +199 -0
  107. package/src/ai/ToolExecutor.ts +193 -0
  108. package/src/ai/index.ts +5 -0
  109. package/src/handlers/StreamWriterFactory.ts +84 -0
  110. package/src/index.ts +4 -0
  111. package/src/utils.ts +52 -0
  112. package/tsconfig.json +6 -1
@@ -0,0 +1,770 @@
1
+ import { FlinkContext } from "../FlinkContext";
2
+ import { log } from "../FlinkLog";
3
+ import { toKebabCase } from "../utils";
4
+ import { AgentRunner } from "./AgentRunner";
5
+ import { LLMMessage } from "./LLMAdapter";
6
+ import { FlinkToolFile } from "./FlinkTool";
7
+ import { ToolExecutor } from "./ToolExecutor";
8
+ import { forbidden } from "../FlinkErrors";
9
+
10
+ export interface FlinkAgentProps {
11
+ id?: string; // Optional agent id (defaults to kebab-case class name if not provided)
12
+ description: string;
13
+
14
+ /**
15
+ * Instructions that define the agent's behavior and personality.
16
+ * Converted to a system message and prepended to the conversation.
17
+ * Follows Vercel AI SDK pattern.
18
+ *
19
+ * @example "You are a helpful customer support agent for Acme Corp."
20
+ */
21
+ instructions: string;
22
+
23
+ tools: Array<string | FlinkToolFile>; // Tool ids or tool file references (validated at startup)
24
+ agents?: Array<string | (new () => FlinkAgent<any>)>; // Sub-agent ids or class references (validated at startup, auto-converted to tools)
25
+ model?: {
26
+ /**
27
+ * ID of the LLM adapter to use (must be registered in FlinkOptions.ai.llmAdapters)
28
+ * Examples: "anthropic", "openai", "anthropic-eu", "gpt4", etc.
29
+ * Defaults to "default" if not specified
30
+ */
31
+ adapterId?: string;
32
+ maxTokens?: number;
33
+ temperature?: number;
34
+ };
35
+ limits?: {
36
+ maxSteps?: number; // Default: 10
37
+ timeoutMs?: number; // Phase 2: Not yet implemented
38
+ maxSubAgentDepth?: number; // Default: 5 - prevents infinite sub-agent recursion
39
+ };
40
+ permissions?: string | string[] | ((user?: any) => boolean);
41
+ /**
42
+ * How this agent handles conversations when called as a sub-agent
43
+ * - "inherit": Use parent's conversationId (default) - saves to parent's conversation
44
+ * - "independent": Create own nested conversation for detailed tracking
45
+ * - "none": Don't track conversations
46
+ */
47
+ conversationStrategy?: "inherit" | "independent" | "none";
48
+ /**
49
+ * Enable verbose debug logging for this agent
50
+ * When true, logs detailed information about:
51
+ * - Full LLM requests (messages, tools, parameters)
52
+ * - Full LLM responses (text, tool calls)
53
+ * - Tool execution details (input, output, errors)
54
+ * - Conversation state changes
55
+ *
56
+ * Useful for debugging tool calling issues and understanding agent behavior
57
+ */
58
+ debug?: boolean;
59
+
60
+ // Lifecycle hooks (passed from agent instance)
61
+ beforeRun?: (input: AgentExecuteInput, context: AgentExecuteContext) => void | Promise<void>;
62
+ onStep?: (context: AgentStepContext) => void | Promise<void>;
63
+ afterRun?: (result: AgentExecuteResult, context: AgentFinishContext) => void | Promise<void>;
64
+ transformSubAgentInput?: (subAgentId: string, input: any, context: AgentExecuteContext) => any | Promise<any>;
65
+ onSubAgentCall?: (subAgentId: string, input: any, context: AgentExecuteContext) => void | Promise<void>;
66
+ onSubAgentComplete?: (subAgentId: string, result: AgentExecuteResult, context: AgentExecuteContext) => void | Promise<void>;
67
+ }
68
+
69
+ export type FlinkAgentFile = {
70
+ Agent?: FlinkAgentProps; // Old pattern: declarative props (deprecated)
71
+ default?: new () => FlinkAgent<any>; // New pattern: class extending FlinkAgent
72
+ __file?: string; // Set by compiler
73
+ };
74
+
75
+ /**
76
+ * Base class for Flink agents (similar to FlinkRepo pattern)
77
+ *
78
+ * Agents extend this class and define their configuration as properties.
79
+ * Auto-registered by scanning src/agents/ directory.
80
+ *
81
+ * Tool references are validated at startup to ensure all referenced tools exist.
82
+ *
83
+ * Agents define their own domain-specific entry points that call `this.execute()`.
84
+ * This provides better type safety and developer experience than a generic `run()` method.
85
+ *
86
+ * ## Lifecycle Hooks
87
+ *
88
+ * Agents support lifecycle hooks for advanced orchestration:
89
+ * - `beforeRun`: Load conversation history, prepare context
90
+ * - `onStep`: Save state after each LLM turn
91
+ * - `afterRun`: Persist final conversation state
92
+ * - `transformSubAgentInput`: Compact/brief context before delegating to sub-agents
93
+ * - `onSubAgentCall`: Log/track delegations
94
+ * - `onSubAgentComplete`: Process sub-agent results
95
+ *
96
+ * Example:
97
+ * ```typescript
98
+ * export default class CarAgent extends FlinkAgent<AppCtx> {
99
+ * id = "car-agent"; // Optional: defaults to kebab-case class name "car-agent"
100
+ * description = "Expert in car models";
101
+ * instructions = "You are a car expert...";
102
+ * tools = ["get-cars-tool"]; // Validated at startup
103
+ * agents = [UserAgent, "external-agent"]; // Can use class references or strings
104
+ *
105
+ * // Domain-specific entry points with proper types
106
+ * async searchByBrand(brand: string) {
107
+ * const response = this.execute({
108
+ * message: `Find all ${brand} cars`
109
+ * });
110
+ * return await response.result;
111
+ * }
112
+ *
113
+ * async compareModels(model1: string, model2: string) {
114
+ * const response = this.execute({
115
+ * message: `Compare ${model1} and ${model2}`
116
+ * });
117
+ * return await response.result;
118
+ * }
119
+ *
120
+ * // Hook: Compact context when delegating to sub-agents
121
+ * protected async transformSubAgentInput(subAgentId, input, context) {
122
+ * const summary = await this.summarizeContext(context.conversationId);
123
+ * return { ...input, query: `Context: ${summary}\n\n${input.query}` };
124
+ * }
125
+ * }
126
+ * ```
127
+ */
128
+ export abstract class FlinkAgent<Ctx extends FlinkContext> {
129
+ ctx!: Ctx;
130
+ private runner?: AgentRunner;
131
+ private _boundUser?: any; // User bound via withUser()
132
+ private _boundUserPermissions?: string[]; // User permissions bound via withPermissions()
133
+ private _llmAdapters?: Map<string, any>;
134
+ private _tools?: { [x: string]: ToolExecutor<Ctx> };
135
+
136
+ // Abstract properties (must be defined by subclass)
137
+ abstract description: string;
138
+ abstract instructions: string;
139
+ abstract tools: Array<string | FlinkToolFile>; // Tool ids or tool file references
140
+
141
+ // Optional properties
142
+ id?: string; // Optional agent id (defaults to kebab-case class name if not provided)
143
+ agents?: Array<string | (new () => FlinkAgent<any>)>; // Sub-agent ids or class references (auto-converted to special tools)
144
+ model?: {
145
+ adapterId?: string;
146
+ maxTokens?: number;
147
+ temperature?: number;
148
+ };
149
+ limits?: { maxSteps?: number; timeoutMs?: number; maxSubAgentDepth?: number };
150
+ permissions?: string | string[] | ((user?: any) => boolean);
151
+ conversationStrategy?: "inherit" | "independent" | "none"; // How to handle conversations as sub-agent
152
+ debug?: boolean; // Enable verbose debug logging
153
+
154
+ /**
155
+ * Internal initialization called by FlinkApp
156
+ * @internal
157
+ */
158
+ __init(llmAdapters: Map<string, any>, tools: { [x: string]: ToolExecutor<Ctx> }): void {
159
+ this._llmAdapters = llmAdapters;
160
+ this._tools = tools;
161
+ }
162
+
163
+ /**
164
+ * Bind a user to this agent for permission checks
165
+ *
166
+ * This creates a new agent instance with the user bound, allowing clean API:
167
+ * ```typescript
168
+ * const result = await ctx.agents.carAgent
169
+ * .withUser(req.user)
170
+ * .searchByBrand("Volvo");
171
+ * ```
172
+ *
173
+ * The bound user is used for:
174
+ * - Agent-level permission checks
175
+ * - Tool filtering (only allowed tools shown to LLM)
176
+ * - Tool-level permission checks
177
+ */
178
+ withUser(user: any): this {
179
+ const bound = Object.create(Object.getPrototypeOf(this));
180
+ Object.assign(bound, this);
181
+ bound._boundUser = user;
182
+ bound.runner = undefined; // Clear runner cache to use new user
183
+ // Explicitly ensure ctx and internal properties are copied (in case they're not enumerable)
184
+ if (this.ctx) {
185
+ bound.ctx = this.ctx;
186
+ }
187
+ if (this._llmAdapters) {
188
+ bound._llmAdapters = this._llmAdapters;
189
+ }
190
+ if (this._tools) {
191
+ bound._tools = this._tools;
192
+ }
193
+ if (this._boundUserPermissions !== undefined) {
194
+ bound._boundUserPermissions = this._boundUserPermissions;
195
+ }
196
+ return bound;
197
+ }
198
+
199
+ /**
200
+ * Bind resolved permissions to this agent for permission checks
201
+ *
202
+ * This creates a new agent instance with permissions bound, allowing clean API:
203
+ * ```typescript
204
+ * const result = await ctx.agents.carAgent
205
+ * .withUser(req.user)
206
+ * .withPermissions(req.userPermissions) // Resolved permissions from auth plugin
207
+ * .searchByBrand("Volvo");
208
+ * ```
209
+ *
210
+ * The bound permissions are used for:
211
+ * - Tool filtering (only allowed tools shown to LLM)
212
+ * - Tool-level permission checks
213
+ *
214
+ * Permissions are typically populated by auth plugins during authentication
215
+ * based on roles, dynamic roles, or custom permission resolution.
216
+ */
217
+ withPermissions(userPermissions?: string[]): this {
218
+ const bound = Object.create(Object.getPrototypeOf(this));
219
+ Object.assign(bound, this);
220
+ bound._boundUserPermissions = userPermissions;
221
+ bound.runner = undefined; // Clear runner cache to use new permissions
222
+ // Explicitly ensure ctx and internal properties are copied (in case they're not enumerable)
223
+ if (this.ctx) {
224
+ bound.ctx = this.ctx;
225
+ }
226
+ if (this._llmAdapters) {
227
+ bound._llmAdapters = this._llmAdapters;
228
+ }
229
+ if (this._tools) {
230
+ bound._tools = this._tools;
231
+ }
232
+ if (this._boundUser !== undefined) {
233
+ bound._boundUser = this._boundUser;
234
+ }
235
+ return bound;
236
+ }
237
+
238
+ /**
239
+ * Override the LLM adapter for this agent
240
+ *
241
+ * This creates a new agent instance with a different LLM adapter, allowing runtime selection:
242
+ * ```typescript
243
+ * const result = await ctx.agents.carAgent
244
+ * .withUser(req.user)
245
+ * .withLlm("fast") // Use fast LLM instead of default
246
+ * .searchByBrand("Volvo");
247
+ * ```
248
+ *
249
+ * The LLM adapter ID must be registered in FlinkApp's ai.llms configuration.
250
+ *
251
+ * @param adapterId - The ID of the LLM adapter to use (e.g., "default", "fake", "fast", "anthropic")
252
+ * @returns A new agent instance with the specified LLM adapter
253
+ */
254
+ withLlm(adapterId: string): this {
255
+ const bound = Object.create(Object.getPrototypeOf(this));
256
+ Object.assign(bound, this);
257
+ // Override the model configuration with the new adapter ID
258
+ bound.model = {
259
+ ...(this.model || {}),
260
+ adapterId,
261
+ };
262
+ bound.runner = undefined; // Clear runner cache to use new adapter
263
+ // Explicitly ensure ctx and internal properties are copied (in case they're not enumerable)
264
+ if (this.ctx) {
265
+ bound.ctx = this.ctx;
266
+ }
267
+ if (this._llmAdapters) {
268
+ bound._llmAdapters = this._llmAdapters;
269
+ }
270
+ if (this._tools) {
271
+ bound._tools = this._tools;
272
+ }
273
+ if (this._boundUser !== undefined) {
274
+ bound._boundUser = this._boundUser;
275
+ }
276
+ if (this._boundUserPermissions !== undefined) {
277
+ bound._boundUserPermissions = this._boundUserPermissions;
278
+ }
279
+ return bound;
280
+ }
281
+
282
+ /**
283
+ * Public execution method for external callers (handlers, sub-agents, etc.)
284
+ *
285
+ * Use this when calling an agent from outside the agent class.
286
+ * For internal use within agent subclasses, use `execute()` instead.
287
+ */
288
+ public run(input: AgentExecuteInput): AgentResponse {
289
+ return this.execute(input);
290
+ }
291
+
292
+ /**
293
+ * Internal execution method - supports both awaiting and streaming
294
+ *
295
+ * Uses lazy generator pattern (similar to Vercel AI SDK) to allow
296
+ * multiple consumption without re-execution.
297
+ *
298
+ * Agents call this method from their run() implementation to execute the AI logic.
299
+ *
300
+ * Examples:
301
+ * const response = this.execute({ message: "Hello" });
302
+ * const result = await response.result; // Await final result
303
+ * for await (const text of response.textStream) { ... } // Stream text
304
+ * for await (const chunk of response.fullStream) { ... } // Stream all events
305
+ */
306
+ protected execute(input: AgentExecuteInput): AgentResponse {
307
+ // Use bound user if not explicitly provided in input
308
+ const user = input.user ?? this._boundUser;
309
+ const userPermissions = input.userPermissions ?? this._boundUserPermissions;
310
+ const executeInput = { ...input, user, userPermissions };
311
+
312
+ // Permission check
313
+ if (this.permissions) {
314
+ this.checkPermissionsSync(user, userPermissions);
315
+ }
316
+
317
+ // Build execution context
318
+ const execContext: AgentExecuteContext = {
319
+ agentId: this.getAgentId(),
320
+ conversationId: executeInput.conversationId,
321
+ user,
322
+ isSubAgent: executeInput.metadata?.isSubAgentCall === true,
323
+ parentAgentId: executeInput.metadata?.parentAgentId,
324
+ subAgentDepth: executeInput.metadata?.subAgentDepth ?? 0,
325
+ metadata: executeInput.metadata,
326
+ };
327
+
328
+ // Call optional beforeRun hook with context
329
+ if (this.beforeRun) {
330
+ const result = this.beforeRun(executeInput, execContext);
331
+ if (result instanceof Promise) {
332
+ // If beforeRun is async, we need to handle it properly
333
+ // For now, we'll let the runner handle async hooks
334
+ }
335
+ }
336
+
337
+ const runner = this.getRunner();
338
+ log.debug(`Running agent ${this.constructor.name}`);
339
+
340
+ // Lazy evaluation - generator only starts when first consumed
341
+ let baseGenerator: AsyncGenerator<StreamChunk> | null = null;
342
+ const buffer: StreamChunk[] = [];
343
+ let done = false;
344
+ let fetchPromise: Promise<void> | null = null; // Prevent concurrent fetches
345
+
346
+ const getBaseGenerator = () => {
347
+ if (!baseGenerator) {
348
+ baseGenerator = runner.streamGenerator(executeInput);
349
+ }
350
+ return baseGenerator;
351
+ };
352
+
353
+ // Fetch next chunk from base generator (only one consumer at a time)
354
+ const fetchNext = async (): Promise<void> => {
355
+ if (fetchPromise) {
356
+ // Another iterator is already fetching, wait for it
357
+ await fetchPromise;
358
+ return;
359
+ }
360
+
361
+ if (done) {
362
+ return;
363
+ }
364
+
365
+ fetchPromise = (async () => {
366
+ const gen = getBaseGenerator();
367
+ const { value, done: isDone } = await gen.next();
368
+
369
+ if (isDone) {
370
+ done = true;
371
+ } else {
372
+ buffer.push(value);
373
+ }
374
+ })();
375
+
376
+ await fetchPromise;
377
+ fetchPromise = null;
378
+ };
379
+
380
+ // Create independent iterators that share buffered chunks
381
+ const createIterator = (): AsyncGenerator<StreamChunk> => {
382
+ let index = 0;
383
+
384
+ return (async function* () {
385
+ while (true) {
386
+ // Yield buffered chunks first
387
+ if (index < buffer.length) {
388
+ yield buffer[index++];
389
+ continue;
390
+ }
391
+
392
+ // If already done, exit
393
+ if (done) {
394
+ break;
395
+ }
396
+
397
+ // Fetch next chunk (synchronized)
398
+ await fetchNext();
399
+
400
+ // Check again after fetch
401
+ if (index < buffer.length) {
402
+ yield buffer[index++];
403
+ } else if (done) {
404
+ break;
405
+ }
406
+ }
407
+ })();
408
+ };
409
+
410
+ return {
411
+ result: this.consumeAsResult(createIterator()),
412
+ textStream: this.consumeAsTextStream(createIterator()),
413
+ fullStream: createIterator(),
414
+ };
415
+ }
416
+
417
+ // Optional lifecycle hooks
418
+ /**
419
+ * Called before agent execution starts
420
+ * Use this to load conversation history, prepare context, etc.
421
+ *
422
+ * @example
423
+ * protected async beforeRun(input: AgentExecuteInput, context: AgentExecuteContext) {
424
+ * if (input.conversationId) {
425
+ * const conv = await this.ctx.repos.conversationRepo.getById(input.conversationId);
426
+ * input.history = conv?.messages;
427
+ * }
428
+ * }
429
+ */
430
+ protected beforeRun?(input: AgentExecuteInput, context: AgentExecuteContext): void | Promise<void>;
431
+
432
+ /**
433
+ * Called after each step (LLM call + tool executions)
434
+ * Use this to save intermediate state, emit progress events, etc.
435
+ *
436
+ * @example
437
+ * protected async onStep(context: AgentStepContext) {
438
+ * if (context.conversationId) {
439
+ * await this.saveConversationState(context.conversationId, context.messages);
440
+ * }
441
+ * }
442
+ */
443
+ protected onStep?(context: AgentStepContext): void | Promise<void>;
444
+
445
+ /**
446
+ * Called when agent execution completes
447
+ * This is where you typically save the final conversation state
448
+ *
449
+ * @example
450
+ * protected async afterRun(result: AgentExecuteResult, context: AgentFinishContext) {
451
+ * if (context.conversationId && !context.isSubAgent) {
452
+ * await this.ctx.repos.conversationRepo.save({
453
+ * id: context.conversationId,
454
+ * messages: context.messages,
455
+ * result
456
+ * });
457
+ * }
458
+ * }
459
+ */
460
+ protected afterRun?(result: AgentExecuteResult, context: AgentFinishContext): void | Promise<void>;
461
+
462
+ /**
463
+ * Transform/compact input before sending to sub-agent
464
+ * Parent agent can summarize context, add briefings, or modify the query
465
+ *
466
+ * Return value becomes the new input sent to the sub-agent
467
+ *
468
+ * @example
469
+ * protected async transformSubAgentInput(subAgentId: string, input: any, context: AgentExecuteContext) {
470
+ * // Load parent conversation
471
+ * const messages = await this.loadConversation(context.conversationId);
472
+ *
473
+ * // Summarize relevant context
474
+ * const summary = this.compactContext(messages, subAgentId);
475
+ *
476
+ * // Add briefing to query
477
+ * return {
478
+ * ...input,
479
+ * query: `Context: ${summary}\n\nQuery: ${input.query}`
480
+ * };
481
+ * }
482
+ */
483
+ protected transformSubAgentInput?(subAgentId: string, input: any, context: AgentExecuteContext): any | Promise<any>;
484
+
485
+ /**
486
+ * Called when a sub-agent is about to be invoked (after transformSubAgentInput)
487
+ * Parent agent can log, or track the delegation
488
+ * Cannot modify input - use transformSubAgentInput for that
489
+ */
490
+ protected onSubAgentCall?(subAgentId: string, input: any, context: AgentExecuteContext): void | Promise<void>;
491
+
492
+ /**
493
+ * Called after sub-agent completes
494
+ * Parent agent can process the result, track metrics, etc.
495
+ */
496
+ protected onSubAgentComplete?(subAgentId: string, result: AgentExecuteResult, context: AgentExecuteContext): void | Promise<void>;
497
+
498
+ /**
499
+ * Consume stream as final result (for await pattern)
500
+ */
501
+ private async consumeAsResult(stream: AsyncGenerator<StreamChunk>): Promise<AgentExecuteResult> {
502
+ let result: AgentExecuteResult | undefined;
503
+
504
+ try {
505
+ for await (const chunk of stream) {
506
+ if (chunk.type === "complete") {
507
+ result = chunk.result;
508
+ break; // Exit early once we have the result
509
+ }
510
+ }
511
+ } catch (err) {
512
+ // If stream was already consumed or interrupted, that's okay
513
+ // as long as we got the result
514
+ if (!result) {
515
+ throw err;
516
+ }
517
+ }
518
+
519
+ if (!result) {
520
+ throw new Error("Agent execution did not complete");
521
+ }
522
+
523
+ // Note: afterRun hook is called by AgentRunner now with full context
524
+ return result;
525
+ }
526
+
527
+ /**
528
+ * Consume stream as text-only stream (for simple streaming UX)
529
+ */
530
+ private async *consumeAsTextStream(stream: AsyncGenerator<StreamChunk>): AsyncGenerator<string> {
531
+ for await (const chunk of stream) {
532
+ if (chunk.type === "text_delta") {
533
+ yield chunk.delta;
534
+ }
535
+ }
536
+ }
537
+
538
+ private getRunner(): AgentRunner {
539
+ if (!this.runner) {
540
+ if (!this._llmAdapters) {
541
+ throw new Error("Agent not initialized - __init() must be called by FlinkApp");
542
+ }
543
+
544
+ // Get tools map and LLM adapters from internal properties
545
+ const toolsMap = this.resolveTools();
546
+ const llmAdapters = this._llmAdapters;
547
+
548
+ this.runner = new AgentRunner(this.toAgentProps(), toolsMap, llmAdapters, this.getAgentId());
549
+ }
550
+ return this.runner;
551
+ }
552
+
553
+ /**
554
+ * Get agent id - uses explicit id property or falls back to kebab-case class name
555
+ *
556
+ * Examples:
557
+ * - CarAgent → car-agent
558
+ * - APIAgent → api-agent
559
+ * - HTMLParserAgent → html-parser-agent
560
+ */
561
+ private getAgentId(): string {
562
+ if (this.id) {
563
+ return this.id;
564
+ }
565
+ // Convert class name to kebab-case using shared utility
566
+ return toKebabCase(this.constructor.name);
567
+ }
568
+
569
+ private toAgentProps(): FlinkAgentProps {
570
+ return {
571
+ id: this.getAgentId(),
572
+ description: this.description,
573
+ instructions: this.instructions,
574
+ tools: this.tools,
575
+ agents: this.agents,
576
+ model: this.model,
577
+ limits: this.limits,
578
+ permissions: this.permissions,
579
+ conversationStrategy: this.conversationStrategy,
580
+ debug: this.debug,
581
+ // Pass lifecycle hooks
582
+ beforeRun: this.beforeRun?.bind(this),
583
+ onStep: this.onStep?.bind(this),
584
+ afterRun: this.afterRun?.bind(this),
585
+ transformSubAgentInput: this.transformSubAgentInput?.bind(this),
586
+ onSubAgentCall: this.onSubAgentCall?.bind(this),
587
+ onSubAgentComplete: this.onSubAgentComplete?.bind(this),
588
+ };
589
+ }
590
+
591
+ private resolveTools(): Map<string, ToolExecutor<Ctx>> {
592
+ const toolsMap = new Map<string, ToolExecutor<Ctx>>();
593
+
594
+ if (!this._tools) {
595
+ throw new Error("Agent not initialized - __init() must be called by FlinkApp");
596
+ }
597
+
598
+ const getTool = (name: string) => this._tools![name];
599
+
600
+ // Resolve tool names/references to tool executors
601
+ for (const toolRef of this.tools) {
602
+ // Handle both string IDs and tool file references (similar to agent resolution)
603
+ const toolId = typeof toolRef === "string" ? toolRef : toolRef.Tool.id; // Extract ID from FlinkToolFile
604
+
605
+ const tool = getTool(toolId);
606
+ if (!tool) {
607
+ throw new Error(`Tool ${toolId} not found in context`);
608
+ }
609
+ toolsMap.set(toolId, tool);
610
+ }
611
+
612
+ // Auto-include sub-agent tools
613
+ if (this.agents && this.agents.length > 0) {
614
+ for (const agentRef of this.agents) {
615
+ // Convert class reference to kebab-case id
616
+ const agentId = typeof agentRef === "string" ? agentRef : agentRef.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
617
+
618
+ const subAgentToolName = `ask_${agentId.replace(/-/g, "_")}`;
619
+ const subAgentTool = getTool(subAgentToolName);
620
+ if (!subAgentTool) {
621
+ throw new Error(`Sub-agent tool ${subAgentToolName} not found in context for agent ${agentId}`);
622
+ }
623
+ toolsMap.set(subAgentToolName, subAgentTool);
624
+ }
625
+ }
626
+
627
+ return toolsMap;
628
+ }
629
+
630
+ private checkPermissionsSync(user?: any, userPermissions?: string[]): void {
631
+ const perms = this.permissions;
632
+ if (!perms) return;
633
+
634
+ if (typeof perms === "function") {
635
+ const hasPermission = perms(user);
636
+ if (!hasPermission) {
637
+ throw forbidden(`Permission denied for agent ${this.getAgentId()}`);
638
+ }
639
+ return;
640
+ }
641
+
642
+ if (!user) {
643
+ throw forbidden(`Permission denied for agent ${this.getAgentId()}`);
644
+ }
645
+
646
+ // Prefer userPermissions from request if available
647
+ const effectivePerms = userPermissions || user.permissions || [];
648
+
649
+ const requiredPerms = Array.isArray(perms) ? perms : [perms];
650
+
651
+ if (!requiredPerms.every((p) => effectivePerms.includes(p))) {
652
+ throw forbidden(`Permission denied for agent ${this.getAgentId()}`);
653
+ }
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Execution context shared across agent lifecycle hooks
659
+ */
660
+ export interface AgentExecuteContext {
661
+ agentId: string;
662
+ conversationId?: string;
663
+ user?: any;
664
+ isSubAgent: boolean; // True if this is a sub-agent call
665
+ parentAgentId?: string; // ID of parent agent if sub-agent
666
+ subAgentDepth: number; // Current depth in sub-agent call chain (0 for root)
667
+ metadata?: Record<string, any>; // Arbitrary metadata from input
668
+ }
669
+
670
+ /**
671
+ * Context for onStep hook - includes current conversation state
672
+ */
673
+ export interface AgentStepContext extends AgentExecuteContext {
674
+ step: number;
675
+ maxSteps: number;
676
+ messages: LLMMessage[]; // Current conversation state (read-only)
677
+ }
678
+
679
+ /**
680
+ * Context for afterRun hook - includes final conversation and result
681
+ */
682
+ export interface AgentFinishContext extends AgentExecuteContext {
683
+ messages: LLMMessage[]; // Full conversation including final turn (read-only)
684
+ result: AgentExecuteResult;
685
+ }
686
+
687
+ /**
688
+ * Message structure for agent conversations
689
+ * Supports both simple strings and structured message arrays
690
+ */
691
+ export type Message =
692
+ | { role: "user"; content: string }
693
+ | { role: "assistant"; content: string; toolCalls?: ToolCall[] }
694
+ | { role: "tool"; toolCallId: string; toolName: string; result: string };
695
+
696
+ export interface ToolCall {
697
+ id: string;
698
+ name: string;
699
+ input: any;
700
+ }
701
+
702
+ export interface AgentExecuteInput {
703
+ message: string | Message[]; // Accept either simple string or structured messages
704
+ user?: any;
705
+ userPermissions?: string[]; // Resolved permissions from auth plugin
706
+
707
+ conversationId?: string; // Optional ID for tracking conversations
708
+
709
+ /**
710
+ * Previous conversation messages for context
711
+ * Agent can load this in beforeRun hook based on conversationId
712
+ */
713
+ history?: Message[];
714
+
715
+ /**
716
+ * Arbitrary metadata that flows through the execution
717
+ * Available in all callbacks
718
+ */
719
+ metadata?: Record<string, any>;
720
+
721
+ options?: { maxSteps?: number; timeoutMs?: number };
722
+ }
723
+
724
+ export interface AgentExecuteResult {
725
+ message: string; // Final AI response
726
+ toolCalls: Array<{
727
+ name: string;
728
+ input: any;
729
+ output: any;
730
+ error?: string;
731
+ isAgentCall?: boolean; // True if this was a sub-agent delegation
732
+ agentId?: string; // ID of the sub-agent that was called
733
+ }>;
734
+ stepsUsed: number;
735
+ usage?: { inputTokens: number; outputTokens: number };
736
+ stoppedEarly: boolean; // True if max steps reached
737
+ subAgentCalls?: Array<{
738
+ agentId: string;
739
+ input: any;
740
+ result: AgentExecuteResult; // Nested result from sub-agent
741
+ }>;
742
+ }
743
+
744
+ /**
745
+ * Stream chunk types for different streaming events
746
+ *
747
+ * Phase 1: Only "complete" event is emitted
748
+ * Phase 2: Will emit real-time "text_delta", "tool_call_start", and "tool_call_result" events
749
+ */
750
+ export type StreamChunk =
751
+ | { type: "text_delta"; delta: string } // Phase 2: Stream text as it's generated
752
+ | { type: "tool_call_start"; toolCall: ToolCall } // Phase 2: Tool execution started
753
+ | {
754
+ type: "tool_call_result"; // Phase 2: Tool execution completed
755
+ toolCall: ToolCall;
756
+ output: any;
757
+ error?: string;
758
+ }
759
+ | { type: "agent_call_start"; agentId: string; input: any } // Sub-agent started
760
+ | { type: "agent_call_result"; agentId: string; result: AgentExecuteResult } // Sub-agent completed
761
+ | { type: "complete"; result: AgentExecuteResult }; // Phase 1: Final result only
762
+
763
+ /**
764
+ * Unified response that supports both awaiting and streaming
765
+ */
766
+ export interface AgentResponse {
767
+ result: Promise<AgentExecuteResult>; // Await for final result
768
+ textStream: AsyncGenerator<string>; // Stream only text deltas
769
+ fullStream: AsyncGenerator<StreamChunk>; // Stream all events
770
+ }