@theihtisham/ai-agent-starter-kit 1.0.0
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/.env.example +33 -0
- package/Dockerfile +35 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/docker-compose.yml +28 -0
- package/next-env.d.ts +5 -0
- package/next.config.mjs +17 -0
- package/package.json +85 -0
- package/postcss.config.js +6 -0
- package/prisma/schema.prisma +157 -0
- package/prisma/seed.ts +46 -0
- package/src/app/(auth)/forgot-password/page.tsx +56 -0
- package/src/app/(auth)/layout.tsx +7 -0
- package/src/app/(auth)/login/page.tsx +83 -0
- package/src/app/(auth)/signup/page.tsx +108 -0
- package/src/app/(dashboard)/agents/[id]/edit/page.tsx +68 -0
- package/src/app/(dashboard)/agents/[id]/page.tsx +114 -0
- package/src/app/(dashboard)/agents/new/page.tsx +43 -0
- package/src/app/(dashboard)/agents/page.tsx +63 -0
- package/src/app/(dashboard)/api-keys/page.tsx +139 -0
- package/src/app/(dashboard)/dashboard/page.tsx +79 -0
- package/src/app/(dashboard)/layout.tsx +16 -0
- package/src/app/(dashboard)/settings/billing/page.tsx +59 -0
- package/src/app/(dashboard)/settings/page.tsx +45 -0
- package/src/app/(dashboard)/usage/page.tsx +46 -0
- package/src/app/api/agents/[id]/chat/route.ts +100 -0
- package/src/app/api/agents/[id]/chats/route.ts +36 -0
- package/src/app/api/agents/[id]/route.ts +97 -0
- package/src/app/api/agents/route.ts +84 -0
- package/src/app/api/api-keys/[id]/route.ts +25 -0
- package/src/app/api/api-keys/route.ts +72 -0
- package/src/app/api/auth/[...nextauth]/route.ts +5 -0
- package/src/app/api/auth/register/route.ts +53 -0
- package/src/app/api/health/route.ts +26 -0
- package/src/app/api/stripe/checkout/route.ts +37 -0
- package/src/app/api/stripe/plans/route.ts +16 -0
- package/src/app/api/stripe/portal/route.ts +29 -0
- package/src/app/api/stripe/webhook/route.ts +45 -0
- package/src/app/api/usage/route.ts +43 -0
- package/src/app/globals.css +59 -0
- package/src/app/layout.tsx +22 -0
- package/src/app/page.tsx +32 -0
- package/src/app/pricing/page.tsx +25 -0
- package/src/components/agents/agent-form.tsx +137 -0
- package/src/components/agents/model-selector.tsx +35 -0
- package/src/components/agents/tool-selector.tsx +48 -0
- package/src/components/auth-provider.tsx +17 -0
- package/src/components/billing/plan-badge.tsx +23 -0
- package/src/components/billing/pricing-table.tsx +95 -0
- package/src/components/billing/usage-meter.tsx +39 -0
- package/src/components/chat/chat-input.tsx +68 -0
- package/src/components/chat/chat-interface.tsx +152 -0
- package/src/components/chat/chat-message.tsx +50 -0
- package/src/components/chat/chat-sidebar.tsx +49 -0
- package/src/components/chat/code-block.tsx +38 -0
- package/src/components/chat/markdown-renderer.tsx +56 -0
- package/src/components/chat/streaming-text.tsx +46 -0
- package/src/components/dashboard/agent-card.tsx +52 -0
- package/src/components/dashboard/header.tsx +75 -0
- package/src/components/dashboard/sidebar.tsx +52 -0
- package/src/components/dashboard/stat-card.tsx +42 -0
- package/src/components/dashboard/usage-chart.tsx +42 -0
- package/src/components/landing/cta.tsx +30 -0
- package/src/components/landing/features.tsx +75 -0
- package/src/components/landing/hero.tsx +42 -0
- package/src/components/landing/pricing.tsx +28 -0
- package/src/components/ui/avatar.tsx +24 -0
- package/src/components/ui/badge.tsx +24 -0
- package/src/components/ui/button.tsx +39 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/dialog.tsx +73 -0
- package/src/components/ui/dropdown.tsx +77 -0
- package/src/components/ui/input.tsx +23 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +48 -0
- package/src/components/ui/tabs.tsx +66 -0
- package/src/components/ui/textarea.tsx +20 -0
- package/src/hooks/use-agent.ts +44 -0
- package/src/hooks/use-streaming.ts +82 -0
- package/src/hooks/use-subscription.ts +40 -0
- package/src/hooks/use-usage.ts +43 -0
- package/src/hooks/use-user.ts +13 -0
- package/src/lib/agents/index.ts +60 -0
- package/src/lib/agents/memory/long-term.ts +241 -0
- package/src/lib/agents/memory/manager.ts +154 -0
- package/src/lib/agents/memory/short-term.ts +155 -0
- package/src/lib/agents/memory/types.ts +68 -0
- package/src/lib/agents/orchestration/debate.ts +170 -0
- package/src/lib/agents/orchestration/index.ts +103 -0
- package/src/lib/agents/orchestration/parallel.ts +143 -0
- package/src/lib/agents/orchestration/router.ts +199 -0
- package/src/lib/agents/orchestration/sequential.ts +127 -0
- package/src/lib/agents/orchestration/types.ts +68 -0
- package/src/lib/agents/tools/calculator.ts +131 -0
- package/src/lib/agents/tools/code-executor.ts +191 -0
- package/src/lib/agents/tools/file-reader.ts +129 -0
- package/src/lib/agents/tools/index.ts +48 -0
- package/src/lib/agents/tools/registry.ts +182 -0
- package/src/lib/agents/tools/web-search.ts +83 -0
- package/src/lib/ai/agent.ts +275 -0
- package/src/lib/ai/context.ts +68 -0
- package/src/lib/ai/memory.ts +98 -0
- package/src/lib/ai/models.ts +80 -0
- package/src/lib/ai/streaming.ts +80 -0
- package/src/lib/ai/tools.ts +149 -0
- package/src/lib/auth/middleware.ts +41 -0
- package/src/lib/auth/nextauth.ts +69 -0
- package/src/lib/db/client.ts +15 -0
- package/src/lib/rate-limit/limiter.ts +93 -0
- package/src/lib/rate-limit/rules.ts +38 -0
- package/src/lib/stripe/client.ts +25 -0
- package/src/lib/stripe/plans.ts +75 -0
- package/src/lib/stripe/usage.ts +123 -0
- package/src/lib/stripe/webhooks.ts +96 -0
- package/src/lib/utils/api-response.ts +85 -0
- package/src/lib/utils/errors.ts +73 -0
- package/src/lib/utils/helpers.ts +50 -0
- package/src/lib/utils/id.ts +21 -0
- package/src/lib/utils/logger.ts +38 -0
- package/src/lib/utils/validation.ts +44 -0
- package/src/middleware.ts +13 -0
- package/src/types/agent.ts +31 -0
- package/src/types/api.ts +38 -0
- package/src/types/billing.ts +35 -0
- package/src/types/chat.ts +30 -0
- package/src/types/next-auth.d.ts +19 -0
- package/tailwind.config.ts +72 -0
- package/tsconfig.json +28 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Registry — Dynamic tool registration and execution.
|
|
3
|
+
*
|
|
4
|
+
* Provides a centralized registry for managing agent tools.
|
|
5
|
+
* Tools can be registered, unregistered, listed, and executed dynamically.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ToolDefinition {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
parameters: Record<
|
|
12
|
+
string,
|
|
13
|
+
{
|
|
14
|
+
type: string;
|
|
15
|
+
description: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
enum?: string[];
|
|
18
|
+
default?: unknown;
|
|
19
|
+
}
|
|
20
|
+
>;
|
|
21
|
+
handler: (params: Record<string, unknown>) => Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToolExecutionResult {
|
|
25
|
+
success: boolean;
|
|
26
|
+
result: string;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ToolRegistry {
|
|
32
|
+
private tools: Map<string, ToolDefinition> = new Map();
|
|
33
|
+
|
|
34
|
+
/** Register a new tool */
|
|
35
|
+
register(tool: ToolDefinition): void {
|
|
36
|
+
if (this.tools.has(tool.name)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Tool "${tool.name}" is already registered. Unregister it first or use a different name.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
this.tools.set(tool.name, tool);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Unregister a tool by name */
|
|
45
|
+
unregister(name: string): boolean {
|
|
46
|
+
return this.tools.delete(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get a tool definition by name */
|
|
50
|
+
get(name: string): ToolDefinition | undefined {
|
|
51
|
+
return this.tools.get(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if a tool is registered */
|
|
55
|
+
has(name: string): boolean {
|
|
56
|
+
return this.tools.has(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** List all registered tools */
|
|
60
|
+
list(): ToolDefinition[] {
|
|
61
|
+
return Array.from(this.tools.values());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** List tool names only */
|
|
65
|
+
listNames(): string[] {
|
|
66
|
+
return Array.from(this.tools.keys());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Execute a tool by name with the given parameters.
|
|
71
|
+
* Returns a structured result with timing and error info.
|
|
72
|
+
*/
|
|
73
|
+
async execute(name: string, params: Record<string, unknown>): Promise<ToolExecutionResult> {
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
const tool = this.tools.get(name);
|
|
76
|
+
|
|
77
|
+
if (!tool) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
result: '',
|
|
81
|
+
durationMs: Date.now() - startTime,
|
|
82
|
+
error: `Tool "${name}" not found. Available: ${this.listNames().join(', ')}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Validate required parameters
|
|
87
|
+
for (const [paramName, paramDef] of Object.entries(tool.parameters)) {
|
|
88
|
+
if (paramDef.required && (params[paramName] === undefined || params[paramName] === null)) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
result: '',
|
|
92
|
+
durationMs: Date.now() - startTime,
|
|
93
|
+
error: `Missing required parameter "${paramName}" for tool "${name}"`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Apply defaults for missing optional parameters
|
|
99
|
+
const fullParams: Record<string, unknown> = { ...params };
|
|
100
|
+
for (const [paramName, paramDef] of Object.entries(tool.parameters)) {
|
|
101
|
+
if (fullParams[paramName] === undefined && paramDef.default !== undefined) {
|
|
102
|
+
fullParams[paramName] = paramDef.default;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const result = await tool.handler(fullParams);
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
result,
|
|
111
|
+
durationMs: Date.now() - startTime,
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
result: '',
|
|
117
|
+
durationMs: Date.now() - startTime,
|
|
118
|
+
error: `Tool "${name}" execution error: ${(err as Error).message}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the tool definitions in OpenAI function-calling format.
|
|
125
|
+
*/
|
|
126
|
+
toOpenAITools(): Array<{
|
|
127
|
+
type: 'function';
|
|
128
|
+
function: {
|
|
129
|
+
name: string;
|
|
130
|
+
description: string;
|
|
131
|
+
parameters: {
|
|
132
|
+
type: 'object';
|
|
133
|
+
properties: Record<string, unknown>;
|
|
134
|
+
required: string[];
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
}> {
|
|
138
|
+
return Array.from(this.tools.values()).map((tool) => ({
|
|
139
|
+
type: 'function' as const,
|
|
140
|
+
function: {
|
|
141
|
+
name: tool.name,
|
|
142
|
+
description: tool.description,
|
|
143
|
+
parameters: {
|
|
144
|
+
type: 'object' as const,
|
|
145
|
+
properties: Object.fromEntries(
|
|
146
|
+
Object.entries(tool.parameters).map(([key, def]) => [
|
|
147
|
+
key,
|
|
148
|
+
{
|
|
149
|
+
type: def.type,
|
|
150
|
+
description: def.description,
|
|
151
|
+
...(def.enum ? { enum: def.enum } : {}),
|
|
152
|
+
},
|
|
153
|
+
]),
|
|
154
|
+
),
|
|
155
|
+
required: Object.entries(tool.parameters)
|
|
156
|
+
.filter(([, def]) => def.required)
|
|
157
|
+
.map(([key]) => key),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Clear all registered tools */
|
|
164
|
+
clear(): void {
|
|
165
|
+
this.tools.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Get the number of registered tools */
|
|
169
|
+
get count(): number {
|
|
170
|
+
return this.tools.size;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Global singleton registry instance */
|
|
175
|
+
let globalRegistry: ToolRegistry | undefined;
|
|
176
|
+
|
|
177
|
+
export function getGlobalRegistry(): ToolRegistry {
|
|
178
|
+
if (!globalRegistry) {
|
|
179
|
+
globalRegistry = new ToolRegistry();
|
|
180
|
+
}
|
|
181
|
+
return globalRegistry;
|
|
182
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Search Tool — Mock implementation returning structured results.
|
|
3
|
+
*
|
|
4
|
+
* In production, replace the handler with a real search API call
|
|
5
|
+
* (SearXNG, SerpAPI, Google Custom Search, Tavily, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolDefinition } from './registry';
|
|
9
|
+
|
|
10
|
+
export interface SearchResult {
|
|
11
|
+
title: string;
|
|
12
|
+
url: string;
|
|
13
|
+
snippet: string;
|
|
14
|
+
relevanceScore: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const webSearchTool: ToolDefinition = {
|
|
18
|
+
name: 'web_search',
|
|
19
|
+
description:
|
|
20
|
+
'Search the web for information. Returns structured results with titles, URLs, and snippets.',
|
|
21
|
+
parameters: {
|
|
22
|
+
query: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Search query string',
|
|
25
|
+
required: true,
|
|
26
|
+
},
|
|
27
|
+
max_results: {
|
|
28
|
+
type: 'number',
|
|
29
|
+
description: 'Maximum number of results to return (1-10)',
|
|
30
|
+
required: false,
|
|
31
|
+
default: 5,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
handler: async (params): Promise<string> => {
|
|
35
|
+
const query = params.query as string;
|
|
36
|
+
const maxResults = Math.min(Math.max((params.max_results as number) ?? 5, 1), 10);
|
|
37
|
+
|
|
38
|
+
// Mock search results — replace with real API in production
|
|
39
|
+
const mockResults: SearchResult[] = generateMockResults(query, maxResults);
|
|
40
|
+
|
|
41
|
+
return formatSearchResults(query, mockResults);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function generateMockResults(query: string, count: number): SearchResult[] {
|
|
46
|
+
const results: SearchResult[] = [];
|
|
47
|
+
const terms = query.split(/\s+/);
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
const term = terms[i % terms.length] ?? query;
|
|
51
|
+
results.push({
|
|
52
|
+
title: `${capitalize(query)} — Result ${i + 1}`,
|
|
53
|
+
url: `https://example.com/search?q=${encodeURIComponent(query)}&result=${i + 1}`,
|
|
54
|
+
snippet: `This is a mock search result for "${term}". In production, this would contain a real snippet from the web page. Configure your search provider (SearXNG, SerpAPI, or Tavily) to get real results.`,
|
|
55
|
+
relevanceScore: 1 - i * 0.1,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatSearchResults(query: string, results: SearchResult[]): string {
|
|
63
|
+
const lines = [`Search results for "${query}":`, ''];
|
|
64
|
+
|
|
65
|
+
for (const result of results) {
|
|
66
|
+
lines.push(`**${result.title}**`);
|
|
67
|
+
lines.push(`URL: ${result.url}`);
|
|
68
|
+
lines.push(`${result.snippet}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push('---');
|
|
73
|
+
lines.push(`Found ${results.length} results.`);
|
|
74
|
+
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function capitalize(str: string): string {
|
|
79
|
+
return str
|
|
80
|
+
.split(' ')
|
|
81
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
82
|
+
.join(' ');
|
|
83
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { Message } from '@prisma/client';
|
|
3
|
+
import { buildChatContext, estimateTokens } from './context';
|
|
4
|
+
import { createSSEStream } from './streaming';
|
|
5
|
+
import { getToolsByIds, type ToolDefinition } from './tools';
|
|
6
|
+
import { logger } from '../utils/logger';
|
|
7
|
+
|
|
8
|
+
export interface AgentConfig {
|
|
9
|
+
model: string;
|
|
10
|
+
systemPrompt: string;
|
|
11
|
+
temperature: number;
|
|
12
|
+
maxTokens: number;
|
|
13
|
+
toolIds: string[];
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
apiBase?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChatResult {
|
|
19
|
+
content: string;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
toolCalls: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Core AI Agent Engine.
|
|
27
|
+
* Supports OpenAI-compatible APIs (OpenAI, local models, etc.)
|
|
28
|
+
*/
|
|
29
|
+
export class AgentEngine {
|
|
30
|
+
private config: AgentConfig;
|
|
31
|
+
private client: OpenAI;
|
|
32
|
+
private tools: ToolDefinition[];
|
|
33
|
+
|
|
34
|
+
constructor(config: AgentConfig) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.client = new OpenAI({
|
|
37
|
+
apiKey: config.apiKey ?? process.env.OPENAI_API_KEY ?? '',
|
|
38
|
+
baseURL: config.apiBase ?? 'https://api.openai.com/v1',
|
|
39
|
+
});
|
|
40
|
+
this.tools = getToolsByIds(config.toolIds);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Non-streaming chat — returns complete response
|
|
45
|
+
*/
|
|
46
|
+
async chat(history: Message[], userMessage: string): Promise<ChatResult> {
|
|
47
|
+
const context = buildChatContext({
|
|
48
|
+
systemPrompt: this.config.systemPrompt,
|
|
49
|
+
history,
|
|
50
|
+
newMessage: userMessage,
|
|
51
|
+
maxTokens: this.config.maxTokens,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const messages: OpenAI.ChatCompletionMessageParam[] = context as unknown as OpenAI.ChatCompletionMessageParam[];
|
|
55
|
+
const openaiTools = this.tools.length > 0 ? this.buildOpenAITools() : undefined;
|
|
56
|
+
|
|
57
|
+
let totalInputTokens = estimateTokens(context.map((m) => m.content).join(''));
|
|
58
|
+
let totalOutputTokens = 0;
|
|
59
|
+
let toolCallCount = 0;
|
|
60
|
+
let finalContent = '';
|
|
61
|
+
|
|
62
|
+
// Conversation loop with tool calling (max 5 rounds)
|
|
63
|
+
for (let round = 0; round < 5; round++) {
|
|
64
|
+
const response = await this.client.chat.completions.create({
|
|
65
|
+
model: this.config.model,
|
|
66
|
+
messages,
|
|
67
|
+
temperature: this.config.temperature,
|
|
68
|
+
max_tokens: Math.min(this.config.maxTokens, 4096),
|
|
69
|
+
tools: openaiTools,
|
|
70
|
+
stream: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const choice = response.choices[0];
|
|
74
|
+
if (!choice) throw new Error('No response choice from AI');
|
|
75
|
+
|
|
76
|
+
const assistantMessage = choice.message;
|
|
77
|
+
totalOutputTokens += response.usage?.completion_tokens ?? 0;
|
|
78
|
+
totalInputTokens = Math.max(totalInputTokens, response.usage?.prompt_tokens ?? 0);
|
|
79
|
+
|
|
80
|
+
// Handle tool calls
|
|
81
|
+
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
82
|
+
toolCallCount += assistantMessage.tool_calls.length;
|
|
83
|
+
messages.push({
|
|
84
|
+
role: 'assistant',
|
|
85
|
+
content: assistantMessage.content ?? '',
|
|
86
|
+
tool_calls: assistantMessage.tool_calls,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const toolCall of assistantMessage.tool_calls) {
|
|
90
|
+
const toolResult = await this.executeToolCall(toolCall);
|
|
91
|
+
messages.push({
|
|
92
|
+
role: 'tool',
|
|
93
|
+
content: toolResult,
|
|
94
|
+
tool_call_id: toolCall.id,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// No tool calls — we have our final answer
|
|
101
|
+
finalContent = assistantMessage.content ?? '';
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
content: finalContent,
|
|
107
|
+
inputTokens: totalInputTokens,
|
|
108
|
+
outputTokens: totalOutputTokens,
|
|
109
|
+
toolCalls: toolCallCount,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Streaming chat — returns ReadableStream for SSE
|
|
115
|
+
*/
|
|
116
|
+
async chatStream(history: Message[], userMessage: string): Promise<{
|
|
117
|
+
stream: ReadableStream<Uint8Array>;
|
|
118
|
+
getResult: () => Promise<ChatResult>;
|
|
119
|
+
}> {
|
|
120
|
+
const context = buildChatContext({
|
|
121
|
+
systemPrompt: this.config.systemPrompt,
|
|
122
|
+
history,
|
|
123
|
+
newMessage: userMessage,
|
|
124
|
+
maxTokens: this.config.maxTokens,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const messages: OpenAI.ChatCompletionMessageParam[] = context as unknown as OpenAI.ChatCompletionMessageParam[];
|
|
128
|
+
const openaiTools = this.tools.length > 0 ? this.buildOpenAITools() : undefined;
|
|
129
|
+
const sse = createSSEStream();
|
|
130
|
+
|
|
131
|
+
let totalInputTokens = estimateTokens(context.map((m) => m.content).join(''));
|
|
132
|
+
let totalOutputTokens = 0;
|
|
133
|
+
let toolCallCount = 0;
|
|
134
|
+
let finalContent = '';
|
|
135
|
+
|
|
136
|
+
const resultPromise = (async (): Promise<ChatResult> => {
|
|
137
|
+
try {
|
|
138
|
+
for (let round = 0; round < 5; round++) {
|
|
139
|
+
const stream = await this.client.chat.completions.create({
|
|
140
|
+
model: this.config.model,
|
|
141
|
+
messages,
|
|
142
|
+
temperature: this.config.temperature,
|
|
143
|
+
max_tokens: Math.min(this.config.maxTokens, 4096),
|
|
144
|
+
tools: openaiTools,
|
|
145
|
+
stream: true,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let currentContent = '';
|
|
149
|
+
let currentToolCalls: OpenAI.ChatCompletionMessageToolCall[] = [];
|
|
150
|
+
|
|
151
|
+
for await (const chunk of stream) {
|
|
152
|
+
const delta = chunk.choices[0]?.delta;
|
|
153
|
+
if (!delta) continue;
|
|
154
|
+
|
|
155
|
+
// Text content
|
|
156
|
+
if (delta.content) {
|
|
157
|
+
currentContent += delta.content;
|
|
158
|
+
sse.send({ type: 'text', content: delta.content });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tool calls
|
|
162
|
+
if (delta.tool_calls) {
|
|
163
|
+
for (const tc of delta.tool_calls) {
|
|
164
|
+
const idx = tc.index ?? 0;
|
|
165
|
+
if (!currentToolCalls[idx]) {
|
|
166
|
+
currentToolCalls[idx] = {
|
|
167
|
+
id: tc.id ?? '',
|
|
168
|
+
type: 'function',
|
|
169
|
+
function: { name: '', arguments: '' },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (tc.id) currentToolCalls[idx].id = tc.id;
|
|
173
|
+
if (tc.function?.name) currentToolCalls[idx].function.name += tc.function.name;
|
|
174
|
+
if (tc.function?.arguments) currentToolCalls[idx].function.arguments += tc.function.arguments;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
totalOutputTokens++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Handle tool calls
|
|
182
|
+
if (currentToolCalls.length > 0) {
|
|
183
|
+
toolCallCount += currentToolCalls.length;
|
|
184
|
+
messages.push({
|
|
185
|
+
role: 'assistant',
|
|
186
|
+
content: currentContent || null,
|
|
187
|
+
tool_calls: currentToolCalls,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const toolCall of currentToolCalls) {
|
|
191
|
+
sse.send({
|
|
192
|
+
type: 'tool_call',
|
|
193
|
+
content: toolCall.function.name,
|
|
194
|
+
metadata: { id: toolCall.id },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const toolResult = await this.executeToolCall(toolCall);
|
|
198
|
+
sse.send({
|
|
199
|
+
type: 'tool_result',
|
|
200
|
+
content: toolResult.slice(0, 500),
|
|
201
|
+
metadata: { toolCallId: toolCall.id },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
messages.push({
|
|
205
|
+
role: 'tool',
|
|
206
|
+
content: toolResult,
|
|
207
|
+
tool_call_id: toolCall.id,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// No tool calls — final answer
|
|
214
|
+
finalContent = currentContent;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
logger.error('Agent streaming error', { error: (err as Error).message });
|
|
219
|
+
sse.send({ type: 'error', content: (err as Error).message });
|
|
220
|
+
} finally {
|
|
221
|
+
sse.close();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
content: finalContent,
|
|
226
|
+
inputTokens: totalInputTokens,
|
|
227
|
+
outputTokens: totalOutputTokens,
|
|
228
|
+
toolCalls: toolCallCount,
|
|
229
|
+
};
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
stream: sse.stream,
|
|
234
|
+
getResult: () => resultPromise,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private buildOpenAITools(): OpenAI.ChatCompletionTool[] {
|
|
239
|
+
return this.tools.map((tool) => ({
|
|
240
|
+
type: 'function' as const,
|
|
241
|
+
function: {
|
|
242
|
+
name: tool.id,
|
|
243
|
+
description: tool.description,
|
|
244
|
+
parameters: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: tool.parameters,
|
|
247
|
+
required: Object.entries(tool.parameters)
|
|
248
|
+
.filter(([, config]) => config.required)
|
|
249
|
+
.map(([key]) => key),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async executeToolCall(
|
|
256
|
+
toolCall: OpenAI.ChatCompletionMessageToolCall,
|
|
257
|
+
): Promise<string> {
|
|
258
|
+
const toolId = toolCall.function.name;
|
|
259
|
+
const tool = this.tools.find((t) => t.id === toolId);
|
|
260
|
+
|
|
261
|
+
if (!tool) {
|
|
262
|
+
return `Tool "${toolId}" not found. Available tools: ${this.tools.map((t) => t.id).join(', ')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const params = JSON.parse(toolCall.function.arguments || '{}');
|
|
267
|
+
logger.info('Executing tool', { tool: toolId, params });
|
|
268
|
+
const result = await tool.handler(params);
|
|
269
|
+
return result;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.error('Tool execution error', { tool: toolId, error: (err as Error).message });
|
|
272
|
+
return `Tool execution error: ${(err as Error).message}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Message } from '@prisma/client';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_CONTEXT_TOKENS = 100000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rough token estimation: ~4 chars per token for English text
|
|
7
|
+
*/
|
|
8
|
+
export function estimateTokens(text: string): number {
|
|
9
|
+
return Math.ceil(text.length / 4);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Trim conversation history to fit within the model's context window.
|
|
14
|
+
* Keeps the system prompt + most recent messages that fit within the budget.
|
|
15
|
+
*/
|
|
16
|
+
export function trimHistory(
|
|
17
|
+
messages: Message[],
|
|
18
|
+
systemPrompt: string,
|
|
19
|
+
maxContextTokens: number = DEFAULT_MAX_CONTEXT_TOKENS,
|
|
20
|
+
reservedForResponse: number = 4096,
|
|
21
|
+
): Message[] {
|
|
22
|
+
const budget = maxContextTokens - reservedForResponse;
|
|
23
|
+
const systemTokens = estimateTokens(systemPrompt);
|
|
24
|
+
let remaining = budget - systemTokens;
|
|
25
|
+
|
|
26
|
+
if (remaining <= 0) return [];
|
|
27
|
+
|
|
28
|
+
// Work backwards from most recent messages
|
|
29
|
+
const selected: Message[] = [];
|
|
30
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
31
|
+
const msg = messages[i];
|
|
32
|
+
if (!msg) break;
|
|
33
|
+
const msgTokens = estimateTokens(msg.content);
|
|
34
|
+
if (msgTokens > remaining) break;
|
|
35
|
+
remaining -= msgTokens;
|
|
36
|
+
selected.unshift(msg);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return selected;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the full context for an AI request
|
|
44
|
+
*/
|
|
45
|
+
export function buildChatContext(params: {
|
|
46
|
+
systemPrompt: string;
|
|
47
|
+
history: Message[];
|
|
48
|
+
newMessage: string;
|
|
49
|
+
maxTokens: number;
|
|
50
|
+
}): Array<{ role: 'system' | 'user' | 'assistant' | 'tool'; content: string }> {
|
|
51
|
+
const { systemPrompt, history, newMessage, maxTokens } = params;
|
|
52
|
+
const trimmed = trimHistory(history, systemPrompt, maxTokens);
|
|
53
|
+
|
|
54
|
+
const messages: Array<{ role: 'system' | 'user' | 'assistant' | 'tool'; content: string }> = [];
|
|
55
|
+
|
|
56
|
+
// System prompt first
|
|
57
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
58
|
+
|
|
59
|
+
// Then trimmed history
|
|
60
|
+
for (const msg of trimmed) {
|
|
61
|
+
messages.push({ role: msg.role as 'system' | 'user' | 'assistant' | 'tool', content: msg.content });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Then the new user message
|
|
65
|
+
messages.push({ role: 'user', content: newMessage });
|
|
66
|
+
|
|
67
|
+
return messages;
|
|
68
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import prisma from '../db/client';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
export interface ConversationMemory {
|
|
5
|
+
agentId: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
maxHistoryMessages: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load conversation history for an agent chat.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadHistory(chatId: string, limit: number = 50) {
|
|
14
|
+
const messages = await prisma.message.findMany({
|
|
15
|
+
where: { chatId },
|
|
16
|
+
orderBy: { createdAt: 'asc' },
|
|
17
|
+
take: limit,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return messages;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Save a message to the conversation.
|
|
25
|
+
*/
|
|
26
|
+
export async function saveMessage(
|
|
27
|
+
chatId: string,
|
|
28
|
+
role: 'user' | 'assistant' | 'system' | 'tool',
|
|
29
|
+
content: string,
|
|
30
|
+
metadata?: Record<string, unknown>,
|
|
31
|
+
) {
|
|
32
|
+
const message = await prisma.message.create({
|
|
33
|
+
data: {
|
|
34
|
+
chatId,
|
|
35
|
+
role,
|
|
36
|
+
content,
|
|
37
|
+
metadata: metadata ? (metadata as never) : undefined,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
logger.debug('Message saved', { chatId, role, messageId: message.id });
|
|
42
|
+
return message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new chat for an agent.
|
|
47
|
+
*/
|
|
48
|
+
export async function createChat(agentId: string, _userId: string, title: string) {
|
|
49
|
+
return prisma.chat.create({
|
|
50
|
+
data: {
|
|
51
|
+
agentId,
|
|
52
|
+
title,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get or create a chat session for ad-hoc conversations.
|
|
59
|
+
*/
|
|
60
|
+
export async function getOrCreateChat(agentId: string, _userId: string, title?: string) {
|
|
61
|
+
const existing = await prisma.chat.findFirst({
|
|
62
|
+
where: { agentId },
|
|
63
|
+
orderBy: { updatedAt: 'desc' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (existing) return existing;
|
|
67
|
+
|
|
68
|
+
return createChat(agentId, _userId, title ?? 'New Chat');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get all chats for an agent.
|
|
73
|
+
*/
|
|
74
|
+
export async function getChatsForAgent(agentId: string, _userId: string) {
|
|
75
|
+
return prisma.chat.findMany({
|
|
76
|
+
where: { agentId },
|
|
77
|
+
orderBy: { updatedAt: 'desc' },
|
|
78
|
+
include: {
|
|
79
|
+
_count: { select: { messages: true } },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete a chat and all its messages.
|
|
86
|
+
*/
|
|
87
|
+
export async function deleteChat(chatId: string, _userId: string) {
|
|
88
|
+
const chat = await prisma.chat.findFirst({
|
|
89
|
+
where: { id: chatId },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!chat) return false;
|
|
93
|
+
|
|
94
|
+
await prisma.message.deleteMany({ where: { chatId } });
|
|
95
|
+
await prisma.chat.delete({ where: { id: chatId } });
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
}
|