@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.
- package/CHANGELOG.md +66 -0
- package/cli/build.ts +8 -1
- package/cli/run.ts +8 -1
- package/dist/cli/build.js +8 -1
- package/dist/cli/run.js +8 -1
- package/dist/src/FlinkApp.d.ts +33 -0
- package/dist/src/FlinkApp.js +279 -35
- package/dist/src/FlinkContext.d.ts +21 -0
- package/dist/src/FlinkHttpHandler.d.ts +152 -9
- package/dist/src/FlinkHttpHandler.js +37 -1
- package/dist/src/TypeScriptCompiler.d.ts +42 -0
- package/dist/src/TypeScriptCompiler.js +346 -4
- package/dist/src/TypeScriptUtils.js +4 -0
- package/dist/src/ai/AgentRunner.d.ts +39 -0
- package/dist/src/ai/AgentRunner.js +625 -0
- package/dist/src/ai/FlinkAgent.d.ts +446 -0
- package/dist/src/ai/FlinkAgent.js +633 -0
- package/dist/src/ai/FlinkTool.d.ts +37 -0
- package/dist/src/ai/FlinkTool.js +2 -0
- package/dist/src/ai/LLMAdapter.d.ts +119 -0
- package/dist/src/ai/LLMAdapter.js +2 -0
- package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
- package/dist/src/ai/SubAgentExecutor.js +220 -0
- package/dist/src/ai/ToolExecutor.d.ts +35 -0
- package/dist/src/ai/ToolExecutor.js +237 -0
- package/dist/src/ai/index.d.ts +5 -0
- package/dist/src/ai/index.js +21 -0
- package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
- package/dist/src/handlers/StreamWriterFactory.js +83 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/utils.d.ts +30 -0
- package/dist/src/utils.js +52 -0
- package/package.json +16 -2
- package/readme.md +425 -0
- package/spec/AgentDuplicateDetection.spec.ts +112 -0
- package/spec/AgentRunner.spec.ts +527 -0
- package/spec/ConversationHooks.spec.ts +290 -0
- package/spec/FlinkAgent.spec.ts +310 -0
- package/spec/FlinkApp.onError.spec.ts +1 -2
- package/spec/FlinkApp.query.spec.ts +107 -0
- package/spec/FlinkApp.validationMode.spec.ts +155 -0
- package/spec/StreamingIntegration.spec.ts +138 -0
- package/spec/SubAgentSupport.spec.ts +941 -0
- package/spec/ToolExecutor.spec.ts +360 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
- package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
- package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
- package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
- package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
- package/spec/mock-project/dist/src/FlinkContext.js +2 -0
- package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
- package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
- package/spec/mock-project/dist/src/FlinkJob.js +2 -0
- package/spec/mock-project/dist/src/FlinkLog.js +26 -0
- package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
- package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
- package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
- package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
- package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
- package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
- package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
- package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
- package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
- package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
- package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
- package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
- package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
- package/spec/mock-project/dist/src/index.js +17 -69
- package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
- package/spec/mock-project/dist/src/utils.js +290 -0
- package/spec/mock-project/tsconfig.json +6 -1
- package/spec/testHelpers.ts +49 -0
- package/spec/utils.caseConversion.spec.ts +80 -0
- package/spec/utils.spec.ts +13 -13
- package/src/FlinkApp.ts +275 -8
- package/src/FlinkContext.ts +22 -0
- package/src/FlinkHttpHandler.ts +164 -10
- package/src/TypeScriptCompiler.ts +398 -7
- package/src/TypeScriptUtils.ts +5 -0
- package/src/ai/AgentRunner.ts +549 -0
- package/src/ai/FlinkAgent.ts +770 -0
- package/src/ai/FlinkTool.ts +40 -0
- package/src/ai/LLMAdapter.ts +96 -0
- package/src/ai/SubAgentExecutor.ts +199 -0
- package/src/ai/ToolExecutor.ts +193 -0
- package/src/ai/index.ts +5 -0
- package/src/handlers/StreamWriterFactory.ts +84 -0
- package/src/index.ts +4 -0
- package/src/utils.ts +52 -0
- 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
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -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
|
+
}
|