@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,40 @@
1
+ import { z } from "zod";
2
+ import { FlinkContext } from "../FlinkContext";
3
+
4
+ /**
5
+ * Standardized tool result format for better error handling
6
+ */
7
+ export type ToolResult<T = any> =
8
+ | { success: true; data: T }
9
+ | { success: false; error: string; code?: string };
10
+
11
+ export interface FlinkToolProps {
12
+ /**
13
+ * Unique identifier for the tool (kebab-case)
14
+ * Used to reference the tool in agents and for registration
15
+ * Example: "get-cars-tool", "search-users-tool"
16
+ */
17
+ id: string;
18
+
19
+ description: string; // For AI understanding
20
+ inputSchema: z.ZodType<any>; // Zod schema for validation + type inference
21
+ outputSchema?: z.ZodType<any>; // Optional output validation (validates the .data field)
22
+ permissions?:
23
+ | string
24
+ | string[]
25
+ | ((input: any, user?: any) => boolean | Promise<boolean>);
26
+ }
27
+
28
+ export interface FlinkTool<
29
+ Ctx extends FlinkContext,
30
+ Input = any,
31
+ Output = any,
32
+ > {
33
+ (params: { input: Input; ctx: Ctx; user?: any }): Promise<ToolResult<Output>>;
34
+ }
35
+
36
+ export type FlinkToolFile = {
37
+ default: FlinkTool<any, any, any>;
38
+ Tool: FlinkToolProps;
39
+ __file?: string; // Set by compiler
40
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Generic message format for LLM conversations
3
+ * Compatible with most LLM providers (Anthropic, OpenAI, etc.)
4
+ */
5
+ export type LLMMessage =
6
+ | { role: "system"; content: string }
7
+ | { role: "user"; content: string | LLMContentBlock[] }
8
+ | { role: "assistant"; content: string | LLMContentBlock[] };
9
+
10
+ export type LLMContentBlock =
11
+ | { type: "text"; text: string }
12
+ | { type: "tool_use"; id: string; name: string; input: any }
13
+ | { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean };
14
+
15
+ /**
16
+ * Standard Flink tool schema format
17
+ * This is the format generated by ToolExecutor.getToolSchema()
18
+ * Adapters convert this to provider-specific formats
19
+ */
20
+ export interface FlinkToolSchema {
21
+ /**
22
+ * Tool name (kebab-case identifier)
23
+ */
24
+ name: string;
25
+
26
+ /**
27
+ * Human-readable description for the LLM
28
+ */
29
+ description: string;
30
+
31
+ /**
32
+ * JSON Schema for tool input validation
33
+ * Generated from Zod schema via z.toJSONSchema()
34
+ */
35
+ inputSchema: Record<string, any>;
36
+ }
37
+
38
+ /**
39
+ * LLM Adapter interface for provider abstraction
40
+ * Enables support for multiple AI providers (Anthropic, OpenAI, Mistral, etc.)
41
+ *
42
+ * All adapters use streaming as the primary method since:
43
+ * - All modern LLM providers support streaming
44
+ * - Better user experience (time-to-first-token <500ms)
45
+ * - Prevents HTTP timeouts for long responses
46
+ * - FlinkAgent's lazy generator pattern allows both streaming and awaiting final result
47
+ *
48
+ * @template ToolSchema - The tool schema format accepted by this adapter (defaults to FlinkToolSchema)
49
+ */
50
+ export interface LLMAdapter<ToolSchema = FlinkToolSchema> {
51
+ /**
52
+ * Optional debug flag to enable detailed logging
53
+ * When enabled, adapters will log:
54
+ * - Full request parameters sent to the LLM provider
55
+ * - Tool call decisions made by the LLM
56
+ * - Token usage and performance metrics
57
+ * - Error details and response metadata
58
+ *
59
+ * Useful for development and troubleshooting.
60
+ * Default: false
61
+ */
62
+ debug?: boolean;
63
+
64
+ /**
65
+ * Stream a completion with tool calling support
66
+ * Returns async generator for streaming responses
67
+ *
68
+ * Even if you want non-streaming UX, use this method and await the final result:
69
+ * ```typescript
70
+ * const response = agent.execute({ message: "Hello" });
71
+ * const result = await response.result; // Non-streaming consumption
72
+ * ```
73
+ */
74
+ stream(params: {
75
+ /**
76
+ * Instructions that define agent behavior and personality.
77
+ * Adapters should prepend this as a system message to the conversation.
78
+ * Following Vercel AI SDK pattern.
79
+ */
80
+ instructions: string;
81
+ messages: LLMMessage[];
82
+ tools: ToolSchema[];
83
+ maxTokens: number;
84
+ temperature: number;
85
+ }): AsyncGenerator<LLMStreamChunk>;
86
+ }
87
+
88
+
89
+ /**
90
+ * Streaming chunk types
91
+ */
92
+ export type LLMStreamChunk =
93
+ | { type: "text"; delta: string }
94
+ | { type: "tool_call"; toolCall: { id: string; name: string; input: any } }
95
+ | { type: "usage"; usage: { inputTokens: number; outputTokens: number } }
96
+ | { type: "done"; stopReason: string };
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ import { FlinkContext } from "../FlinkContext";
3
+ import { ToolResult } from "./FlinkTool";
4
+ import { AgentExecuteResult, FlinkAgent, AgentExecuteContext } from "./FlinkAgent";
5
+ import { log } from "../FlinkLog";
6
+ import { generateShortId, toKebabCase, toSnakeCase } from "../utils";
7
+ import { v4 as generateId } from "uuid";
8
+
9
+ /**
10
+ * Special tool executor that wraps a sub-agent
11
+ * Automatically created for each agent reference in parent agent's `agents` array
12
+ */
13
+ export class SubAgentExecutor<Ctx extends FlinkContext> {
14
+ private subAgentInstanceName: string;
15
+ private subAgentId: string;
16
+
17
+ constructor(
18
+ subAgentInstanceName: string,
19
+ private ctx: Ctx
20
+ ) {
21
+ this.subAgentInstanceName = subAgentInstanceName;
22
+ // Convert instance name to kebab-case for consistency with agent IDs
23
+ this.subAgentId = toKebabCase(subAgentInstanceName);
24
+ }
25
+
26
+ /**
27
+ * Execute the sub-agent with the given input
28
+ */
29
+ async execute(
30
+ input: any,
31
+ user?: any,
32
+ parentContext?: AgentExecuteContext, // NEW: parent agent context
33
+ userPermissions?: string[] // Resolved permissions from auth plugin
34
+ ): Promise<ToolResult<AgentExecuteResult>> {
35
+ try {
36
+ // Get the sub-agent from context
37
+ const agents = this.ctx.agents;
38
+ if (!agents) {
39
+ return {
40
+ success: false,
41
+ error: "Agents not available in context",
42
+ code: "NO_AGENTS",
43
+ };
44
+ }
45
+
46
+ const subAgent: FlinkAgent<Ctx> = agents[this.subAgentInstanceName];
47
+ if (!subAgent) {
48
+ return {
49
+ success: false,
50
+ error: `Sub-agent ${this.subAgentInstanceName} not found`,
51
+ code: "AGENT_NOT_FOUND",
52
+ };
53
+ }
54
+
55
+ // Bind user and permissions if provided
56
+ let boundAgent = subAgent;
57
+ if (user) {
58
+ boundAgent = boundAgent.withUser(user);
59
+ }
60
+ if (userPermissions) {
61
+ boundAgent = boundAgent.withPermissions(userPermissions);
62
+ }
63
+
64
+ // Determine conversation strategy
65
+ const strategy = subAgent.conversationStrategy || "inherit";
66
+
67
+ let conversationId: string | undefined;
68
+
69
+ if (strategy === "inherit") {
70
+ // Use parent's conversation ID
71
+ conversationId = parentContext?.conversationId;
72
+ } else if (strategy === "independent") {
73
+ // Create nested conversation
74
+ conversationId = parentContext?.conversationId
75
+ ? `${parentContext.conversationId}:${this.subAgentInstanceName}:${generateShortId()}`
76
+ : generateId();
77
+ } else {
78
+ // strategy === "none"
79
+ conversationId = undefined;
80
+ }
81
+
82
+ log.debug(
83
+ `Delegating to sub-agent ${this.subAgentInstanceName} from parent ${parentContext?.agentId || "unknown"}, ` +
84
+ `strategy: "${strategy}", conversationId: ${conversationId || "none"}`
85
+ );
86
+
87
+ // Note: Input transformation happens in AgentRunner via transformSubAgentInput hook
88
+ // The input received here is already transformed if the parent agent has that hook
89
+
90
+ // Execute the sub-agent with conversation context
91
+ // Increment depth for sub-agent call to track recursion
92
+ const response = boundAgent.run({
93
+ message: input.query || input.message || JSON.stringify(input),
94
+ user,
95
+ userPermissions,
96
+ conversationId,
97
+ metadata: {
98
+ ...input.metadata,
99
+ parentConversationId: parentContext?.conversationId,
100
+ parentAgentId: parentContext?.agentId,
101
+ parentMetadata: parentContext?.metadata, // Preserve parent chain for error reporting
102
+ isSubAgentCall: true,
103
+ subAgentDepth: (parentContext?.subAgentDepth ?? 0) + 1,
104
+ },
105
+ });
106
+
107
+ const result = await response.result;
108
+
109
+ log.debug(`Sub-agent ${this.subAgentInstanceName} completed with ${result.stepsUsed} steps`);
110
+
111
+ return {
112
+ success: true,
113
+ data: result,
114
+ };
115
+ } catch (err: any) {
116
+ log.error(`Sub-agent ${this.subAgentInstanceName} execution failed:`, err.message);
117
+ return {
118
+ success: false,
119
+ error: `Sub-agent execution failed: ${err.message}`,
120
+ code: "SUB_AGENT_ERROR",
121
+ };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Generate tool schema for this sub-agent
127
+ * LLM sees this as a regular tool
128
+ */
129
+ getToolSchema(): any {
130
+ const agents = this.ctx.agents;
131
+
132
+ if (!agents?.[this.subAgentInstanceName]) {
133
+ throw new Error("Missing subagent: " + this.subAgentInstanceName);
134
+ }
135
+
136
+ const subAgent: FlinkAgent<Ctx> = agents?.[this.subAgentInstanceName];
137
+
138
+ const description = subAgent ? `Delegate to ${this.subAgentId}: ${subAgent.description}` : `Delegate to ${this.subAgentId}`;
139
+
140
+ // Simple schema - accepts a query string
141
+ const inputSchema = z.object({
142
+ query: z.string().describe("The question or task to send to the specialist agent"),
143
+ });
144
+
145
+ // Convert kebab-case ID to snake_case for tool name (ask_car_agent, not ask_car-agent)
146
+ const toolName = `ask_${toSnakeCase(this.subAgentId)}`;
147
+
148
+ return {
149
+ name: toolName,
150
+ description,
151
+ input_schema: (inputSchema as any).schema
152
+ ? (inputSchema as any).schema()
153
+ : {
154
+ type: "object",
155
+ properties: {
156
+ query: { type: "string" },
157
+ },
158
+ required: ["query"],
159
+ },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Format sub-agent result for AI consumption
165
+ */
166
+ formatResultForAI(result: ToolResult<AgentExecuteResult>): string {
167
+ if (result.success) {
168
+ const agentResult = result.data;
169
+ // Return the agent's final message as the tool result
170
+ return JSON.stringify({
171
+ answer: agentResult.message,
172
+ stepsUsed: agentResult.stepsUsed,
173
+ tokensUsed: (agentResult.usage?.inputTokens || 0) + (agentResult.usage?.outputTokens || 0),
174
+ });
175
+ } else {
176
+ return `Error: ${result.error}${result.code ? ` (${result.code})` : ""}`;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Check permissions - sub-agents handle their own permissions
182
+ */
183
+ async checkPermissions(_user?: any, _input?: any, _userPermissions?: string[]): Promise<boolean> {
184
+ // Sub-agents manage their own permissions
185
+ // The sub-agent's permissions will be checked when executed
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Mark this as a sub-agent executor
191
+ */
192
+ isSubAgentExecutor(): boolean {
193
+ return true;
194
+ }
195
+
196
+ getSubAgentId(): string {
197
+ return this.subAgentId;
198
+ }
199
+ }
@@ -0,0 +1,193 @@
1
+ import { FlinkContext } from "../FlinkContext";
2
+ import { log } from "../FlinkLog";
3
+ import { FlinkTool, FlinkToolProps, ToolResult } from "./FlinkTool";
4
+ import { FlinkToolSchema } from "./LLMAdapter";
5
+
6
+ export class ToolExecutor<Ctx extends FlinkContext> {
7
+ constructor(
8
+ private toolProps: FlinkToolProps,
9
+ private toolFn: FlinkTool<Ctx, any, any>,
10
+ private ctx: Ctx
11
+ ) {}
12
+
13
+ async execute(input: any, user?: any, userPermissions?: string[]): Promise<ToolResult<any>> {
14
+ // 1. Permission check
15
+ if (this.toolProps.permissions) {
16
+ const hasPermission = await this.checkPermissionsInternal(input, user, userPermissions);
17
+ if (!hasPermission) {
18
+ return {
19
+ success: false,
20
+ error: `Permission denied for tool ${this.toolProps.id}`,
21
+ code: "PERMISSION_DENIED",
22
+ };
23
+ }
24
+ }
25
+
26
+ // 2. Input validation
27
+ let validatedInput: any;
28
+ try {
29
+ validatedInput = this.toolProps.inputSchema.parse(input);
30
+ } catch (err: any) {
31
+ log.warn(`Tool ${this.toolProps.id} input validation failed:`, err.message);
32
+
33
+ // Format Zod validation errors for better LLM understanding
34
+ const errorDetails = this.formatValidationError(err, input);
35
+
36
+ return {
37
+ success: false,
38
+ error: `Invalid input for tool '${this.toolProps.id}': ${errorDetails}`,
39
+ code: "VALIDATION_ERROR",
40
+ };
41
+ }
42
+
43
+ // 3. Execute tool
44
+ log.debug(`Executing tool ${this.toolProps.id}`);
45
+ let result: ToolResult<any>;
46
+ try {
47
+ result = await this.toolFn({
48
+ input: validatedInput,
49
+ ctx: this.ctx,
50
+ user,
51
+ });
52
+ } catch (err: any) {
53
+ log.error(`Tool ${this.toolProps.id} threw error:`, err.message);
54
+ return {
55
+ success: false,
56
+ error: `Tool execution failed: ${err.message}`,
57
+ code: "EXECUTION_ERROR",
58
+ };
59
+ }
60
+
61
+ // 4. Handle error results
62
+ if (!result.success) {
63
+ log.warn(`Tool ${this.toolProps.id} returned error:`, result.error);
64
+ return result; // Return error result as-is
65
+ }
66
+
67
+ // 5. Output validation (if schema provided)
68
+ if (this.toolProps.outputSchema) {
69
+ try {
70
+ const validatedData = this.toolProps.outputSchema.parse(result.data);
71
+ return { success: true, data: validatedData };
72
+ } catch (err: any) {
73
+ log.error(`Tool ${this.toolProps.id} output validation failed:`, err.message);
74
+ return {
75
+ success: false,
76
+ error: `Invalid output from tool ${this.toolProps.id}: ${err.message}`,
77
+ code: "OUTPUT_VALIDATION_ERROR",
78
+ };
79
+ }
80
+ }
81
+
82
+ return result;
83
+ }
84
+
85
+ getToolSchema(): FlinkToolSchema {
86
+ // Use Zod 4's built-in z.toJSONSchema()
87
+ const zodSchema = this.toolProps.inputSchema;
88
+ const z = require("zod") as any;
89
+
90
+ return {
91
+ name: this.toolProps.id,
92
+ description: this.toolProps.description,
93
+ inputSchema: z.toJSONSchema ? z.toJSONSchema(zodSchema) : this.fallbackSchema(zodSchema),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Fallback schema generation if toJSONSchema is not available
99
+ */
100
+ private fallbackSchema(zodSchema: any): any {
101
+ // Try to use _def to extract basic schema info
102
+ log.warn(`Tool ${this.toolProps.id}: z.toJSONSchema() not available, using fallback schema generation`);
103
+ return {
104
+ type: "object",
105
+ properties: {},
106
+ required: [],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Get tool result for AI consumption
112
+ * Formats ToolResult into string for AI context
113
+ */
114
+ formatResultForAI(result: ToolResult<any>): string {
115
+ if (result.success) {
116
+ return JSON.stringify(result.data);
117
+ } else {
118
+ return `Error: ${result.error}${result.code ? ` (${result.code})` : ""}`;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check if user has permission to use this tool
124
+ * Used by AgentRunner to filter tools before showing to LLM
125
+ *
126
+ * @param user - User object
127
+ * @param input - Tool input (for function-based permissions)
128
+ * @param userPermissions - Optional resolved permissions from auth plugin (preferred)
129
+ */
130
+ async checkPermissions(user?: any, input?: any, userPermissions?: string[]): Promise<boolean> {
131
+ const perms = this.toolProps.permissions;
132
+ if (!perms) return true;
133
+ if (typeof perms === "function") return await perms(input ?? {}, user);
134
+ if (!user) return false;
135
+
136
+ // Prefer userPermissions from request if available
137
+ const effectivePerms = userPermissions || user.permissions || [];
138
+
139
+ const requiredPerms = Array.isArray(perms) ? perms : [perms];
140
+ return requiredPerms.every((p) => effectivePerms.includes(p));
141
+ }
142
+
143
+ private async checkPermissionsInternal(input: any, user?: any, userPermissions?: string[]): Promise<boolean> {
144
+ return this.checkPermissions(user, input, userPermissions);
145
+ }
146
+
147
+ /**
148
+ * Format Zod validation errors into LLM-friendly error messages
149
+ * Provides specific guidance on what's missing or incorrect
150
+ */
151
+ private formatValidationError(err: any, receivedInput: any): string {
152
+ // Check if it's a Zod error with issues array
153
+ if (err.issues && Array.isArray(err.issues)) {
154
+ const issues = err.issues.map((issue: any) => {
155
+ const path = issue.path.join('.');
156
+ const field = path || 'input';
157
+
158
+ if (issue.code === 'invalid_type') {
159
+ if (issue.received === 'undefined') {
160
+ return `Missing required field '${field}' (expected ${issue.expected})`;
161
+ }
162
+ return `Field '${field}' has wrong type: expected ${issue.expected}, got ${issue.received}`;
163
+ }
164
+
165
+ if (issue.code === 'too_small') {
166
+ if (issue.type === 'string') {
167
+ return `Field '${field}' is too short (minimum ${issue.minimum} characters)`;
168
+ }
169
+ return `Field '${field}' is too small (minimum ${issue.minimum})`;
170
+ }
171
+
172
+ if (issue.code === 'too_big') {
173
+ if (issue.type === 'string') {
174
+ return `Field '${field}' is too long (maximum ${issue.maximum} characters)`;
175
+ }
176
+ return `Field '${field}' is too large (maximum ${issue.maximum})`;
177
+ }
178
+
179
+ // Generic fallback
180
+ return `${field}: ${issue.message}`;
181
+ });
182
+
183
+ const inputInfo = Object.keys(receivedInput || {}).length === 0
184
+ ? 'You provided an empty object {}.'
185
+ : `You provided: ${JSON.stringify(receivedInput)}`;
186
+
187
+ return `${issues.join('; ')}. ${inputInfo}`;
188
+ }
189
+
190
+ // Fallback for non-Zod errors
191
+ return err.message || 'Unknown validation error';
192
+ }
193
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./FlinkTool";
2
+ export * from "./FlinkAgent";
3
+ export * from "./ToolExecutor";
4
+ export * from "./AgentRunner";
5
+ export * from "./LLMAdapter";
@@ -0,0 +1,84 @@
1
+ import { Response } from "express";
2
+ import { StreamWriter, StreamFormat } from "../FlinkHttpHandler";
3
+ import { log } from "../FlinkLog";
4
+
5
+ /**
6
+ * Factory for creating StreamWriter instances for SSE and NDJSON streaming.
7
+ *
8
+ * Handles HTTP headers, connection lifecycle, and format-specific serialization.
9
+ */
10
+ export class StreamWriterFactory {
11
+ /**
12
+ * Create a StreamWriter for the given format.
13
+ *
14
+ * Sets appropriate HTTP headers and manages the stream lifecycle including
15
+ * client disconnect detection.
16
+ *
17
+ * @param res - Express response object
18
+ * @param format - Stream format (sse or ndjson)
19
+ * @returns StreamWriter instance for writing data to the stream
20
+ */
21
+ static create<T = any>(res: Response, format: StreamFormat): StreamWriter<T> {
22
+ // Set appropriate headers based on format
23
+ if (format === "sse") {
24
+ res.setHeader("Content-Type", "text/event-stream");
25
+ res.setHeader("Cache-Control", "no-cache");
26
+ res.setHeader("Connection", "keep-alive");
27
+ res.flushHeaders();
28
+ } else if (format === "ndjson") {
29
+ res.setHeader("Content-Type", "application/x-ndjson");
30
+ res.setHeader("Cache-Control", "no-cache");
31
+ res.flushHeaders();
32
+ }
33
+
34
+ let isOpen = true;
35
+
36
+ // Detect client disconnect
37
+ res.on("close", () => {
38
+ isOpen = false;
39
+ });
40
+
41
+ return {
42
+ write: (data: T) => {
43
+ if (!isOpen) return;
44
+
45
+ try {
46
+ const json = JSON.stringify(data);
47
+
48
+ if (format === "sse") {
49
+ res.write(`data: ${json}\n\n`);
50
+ } else if (format === "ndjson") {
51
+ res.write(`${json}\n`);
52
+ }
53
+ } catch (err) {
54
+ log.error("StreamWriter serialization error:", { error: err });
55
+ isOpen = false;
56
+ }
57
+ },
58
+
59
+ error: (error: Error | string) => {
60
+ if (!isOpen) return;
61
+
62
+ const errorMessage = typeof error === "string" ? error : error.message;
63
+
64
+ if (format === "sse") {
65
+ res.write(`event: error\ndata: ${JSON.stringify({ error: errorMessage })}\n\n`);
66
+ } else if (format === "ndjson") {
67
+ res.write(`${JSON.stringify({ error: errorMessage })}\n`);
68
+ }
69
+
70
+ res.end();
71
+ isOpen = false;
72
+ },
73
+
74
+ end: () => {
75
+ if (isOpen) {
76
+ res.end();
77
+ isOpen = false;
78
+ }
79
+ },
80
+
81
+ isOpen: () => isOpen,
82
+ };
83
+ }
84
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,10 @@ export * from "./FlinkPlugin";
9
9
  export * from "./FlinkJob";
10
10
  export * from "./auth/FlinkAuthUser";
11
11
  export * from "./auth/FlinkAuthPlugin";
12
+ export * from "./ai/FlinkTool";
13
+ export * from "./ai/FlinkAgent";
14
+ export * from "./ai/ToolExecutor";
15
+ export * from "./ai/LLMAdapter";
12
16
 
13
17
  // Re-export Express types for plugins and consumer apps
14
18
  // This ensures type consistency across the framework and plugins
package/src/utils.ts CHANGED
@@ -59,6 +59,45 @@ export function getRepoInstanceName(fn: string) {
59
59
  return name.charAt(0).toLowerCase() + name.substr(1);
60
60
  }
61
61
 
62
+ /**
63
+ * Convert PascalCase or camelCase to kebab-case
64
+ *
65
+ * Examples:
66
+ * - FooBarBaz → foo-bar-baz
67
+ * - CarAgent → car-agent
68
+ * - APIAgent → api-agent
69
+ * - HTMLParser → html-parser
70
+ * - IOManager → io-manager
71
+ *
72
+ * Handles:
73
+ * - Single uppercase followed by lowercase: CarAgent → car-agent
74
+ * - Multiple consecutive uppercase: APIAgent → api-agent
75
+ * - All caps: HTML → html
76
+ */
77
+ export function toKebabCase(str: string): string {
78
+ return str
79
+ // Insert hyphen before uppercase letters that follow lowercase letters
80
+ // CarAgent → Car-Agent
81
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
82
+ // Insert hyphen before uppercase letter that follows uppercase and precedes lowercase
83
+ // APIAgent → API-Agent
84
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
85
+ // Convert to lowercase
86
+ .toLowerCase();
87
+ }
88
+
89
+ /**
90
+ * Convert kebab-case to snake_case
91
+ *
92
+ * Examples:
93
+ * - car-agent → car_agent
94
+ * - api-agent → api_agent
95
+ * - html-parser → html_parser
96
+ */
97
+ export function toSnakeCase(str: string): string {
98
+ return str.replace(/-/g, "_");
99
+ }
100
+
62
101
  /**
63
102
  * Get http method from props or convention based on file name
64
103
  * if it starts with i.e "GetFoo"
@@ -177,3 +216,16 @@ export function formatValidationErrors(errors: any[] | null | undefined, data: a
177
216
 
178
217
  return formatted.join("\n");
179
218
  }
219
+
220
+ /**
221
+ * Generate a short random ID for nested conversations
222
+ * Format: 8 character alphanumeric string (e.g., "a3b7c9d2")
223
+ */
224
+ export function generateShortId(): string {
225
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
226
+ let result = "";
227
+ for (let i = 0; i < 8; i++) {
228
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
229
+ }
230
+ return result;
231
+ }