apteva 0.4.3 → 0.4.5

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.
@@ -0,0 +1,638 @@
1
+ import { spawn } from "bun";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { mkdirSync, existsSync, rmSync } from "fs";
5
+ import { agentProcesses, agentsStarting, getBinaryPathForAgent, getBinaryStatus, BIN_DIR, telemetryBroadcaster, type TelemetryEvent } from "../../server";
6
+ import { AgentDB, McpServerDB, SkillDB, TelemetryDB, generateId, getMultiAgentConfig, type Agent, type Project } from "../../db";
7
+ import { ProviderKeys, PROVIDERS, type ProviderId } from "../../providers";
8
+ import { binaryExists } from "../../binary";
9
+
10
+ // Data directory for agent instances (in ~/.apteva/agents/)
11
+ export const AGENTS_DATA_DIR = process.env.DATA_DIR
12
+ ? join(process.env.DATA_DIR, "agents")
13
+ : join(homedir(), ".apteva", "agents");
14
+
15
+ // Meta Agent configuration
16
+ export const META_AGENT_ENABLED = process.env.META_AGENT_ENABLED === "true";
17
+ export const META_AGENT_ID = "apteva-assistant";
18
+
19
+ // Update agent status + emit telemetry event + broadcast to SSE
20
+ export function setAgentStatus(agentId: string, status: "running" | "stopped", reason?: string): Agent | null {
21
+ const agent = AgentDB.setStatus(agentId, status);
22
+ const event: TelemetryEvent = {
23
+ id: generateId(),
24
+ agent_id: agentId,
25
+ timestamp: new Date().toISOString(),
26
+ category: "system",
27
+ type: status === "running" ? "agent_started" : "agent_stopped",
28
+ level: "info",
29
+ data: { reason: reason || status },
30
+ };
31
+ TelemetryDB.insertBatch(agentId, [event]);
32
+ telemetryBroadcaster.broadcast([event]);
33
+ return agent;
34
+ }
35
+
36
+ // Wait for agent to be healthy (with timeout)
37
+ // Note: /health endpoint is whitelisted in agent, no auth needed
38
+ export async function waitForAgentHealth(port: number, maxAttempts = 30, delayMs = 200): Promise<boolean> {
39
+ for (let i = 0; i < maxAttempts; i++) {
40
+ try {
41
+ const res = await fetch(`http://localhost:${port}/health`, {
42
+ signal: AbortSignal.timeout(1000),
43
+ });
44
+ if (res.ok) return true;
45
+ } catch {
46
+ // Not ready yet
47
+ }
48
+ await new Promise(r => setTimeout(r, delayMs));
49
+ }
50
+ return false;
51
+ }
52
+
53
+ // Check if a port is free by trying to connect
54
+ export async function checkPortFree(port: number): Promise<boolean> {
55
+ return new Promise((resolve) => {
56
+ const net = require("net");
57
+ const server = net.createServer();
58
+ server.once("error", () => {
59
+ resolve(false); // Port in use
60
+ });
61
+ server.once("listening", () => {
62
+ server.close();
63
+ resolve(true); // Port is free
64
+ });
65
+ server.listen(port, "127.0.0.1");
66
+ });
67
+ }
68
+
69
+ // Make authenticated request to agent
70
+ export async function agentFetch(
71
+ agentId: string,
72
+ port: number,
73
+ endpoint: string,
74
+ options: RequestInit = {}
75
+ ): Promise<Response> {
76
+ const apiKey = AgentDB.getApiKey(agentId);
77
+ const headers: Record<string, string> = {
78
+ ...(options.headers as Record<string, string> || {}),
79
+ };
80
+ if (apiKey) {
81
+ headers["X-API-Key"] = apiKey;
82
+ }
83
+ return fetch(`http://localhost:${port}${endpoint}`, {
84
+ ...options,
85
+ headers,
86
+ });
87
+ }
88
+
89
+ // Build agent config from apteva agent data
90
+ // Note: POST /config expects flat structure WITHOUT "agent" wrapper
91
+ export function buildAgentConfig(agent: Agent, providerKey: string) {
92
+ const features = agent.features;
93
+
94
+ // Get MCP server details for the agent's selected servers
95
+ const mcpServers: Array<{ name: string; type: "http"; url: string; headers: Record<string, string>; enabled: boolean }> = [];
96
+
97
+ // Get skill definitions for the agent's selected skills
98
+ const skillDefinitions: Array<{
99
+ name: string;
100
+ description: string;
101
+ instructions: string;
102
+ icon: string;
103
+ category: string;
104
+ tags: string[];
105
+ tools: string[];
106
+ enabled: boolean;
107
+ }> = [];
108
+
109
+ for (const skillId of agent.skills || []) {
110
+ const skill = SkillDB.findById(skillId);
111
+ if (!skill || !skill.enabled) continue;
112
+
113
+ skillDefinitions.push({
114
+ name: skill.name,
115
+ description: skill.description,
116
+ instructions: skill.content,
117
+ icon: "",
118
+ category: "",
119
+ tags: [],
120
+ tools: skill.allowed_tools || [],
121
+ enabled: true,
122
+ });
123
+ }
124
+
125
+ for (const id of agent.mcp_servers || []) {
126
+ const server = McpServerDB.findById(id);
127
+ if (!server) continue;
128
+
129
+ if (server.type === "http" && server.url) {
130
+ // Remote HTTP server (Composio, Smithery, or custom)
131
+ mcpServers.push({
132
+ name: server.name,
133
+ type: "http",
134
+ url: server.url,
135
+ headers: server.headers || {},
136
+ enabled: true,
137
+ });
138
+ } else if (server.status === "running" && server.port) {
139
+ // Local MCP server (npm, github, custom)
140
+ mcpServers.push({
141
+ name: server.name,
142
+ type: "http",
143
+ url: `http://localhost:${server.port}/mcp`,
144
+ headers: {},
145
+ enabled: true,
146
+ });
147
+ }
148
+ }
149
+
150
+ return {
151
+ id: agent.id,
152
+ name: agent.name,
153
+ description: agent.system_prompt,
154
+ public_url: `http://localhost:${agent.port}`,
155
+ llm: {
156
+ provider: agent.provider,
157
+ model: agent.model,
158
+ max_tokens: 4000,
159
+ temperature: 0.7,
160
+ system_prompt: agent.system_prompt,
161
+ vision: {
162
+ enabled: features.vision,
163
+ max_images: 20,
164
+ max_image_size: 5242880,
165
+ allowed_types: ["jpeg", "png", "gif", "webp"],
166
+ resize_images: true,
167
+ max_dimension: 1568,
168
+ pdf: {
169
+ enabled: features.vision,
170
+ max_file_size: 33554432,
171
+ max_pages: 100,
172
+ allow_urls: true,
173
+ },
174
+ },
175
+ parallel_tools: {
176
+ enabled: true,
177
+ max_concurrent: 10,
178
+ },
179
+ tools: [], // Clear any old tool whitelist - agent uses all registered tools
180
+ builtin_tools: [
181
+ ...(features.builtinTools?.webSearch ? [{ type: "web_search_20250305", name: "web_search" }] : []),
182
+ ...(features.builtinTools?.webFetch ? [{ type: "web_fetch_20250910", name: "web_fetch" }] : []),
183
+ ],
184
+ },
185
+ tasks: {
186
+ enabled: features.tasks,
187
+ allow_scheduling: true,
188
+ allow_recurring: true,
189
+ max_tasks: 100,
190
+ auto_execute: false,
191
+ },
192
+ scheduler: {
193
+ enabled: features.tasks,
194
+ interval: "1m",
195
+ max_tasks: 100,
196
+ },
197
+ memory: {
198
+ enabled: features.memory,
199
+ embedding_model: "text-embedding-3-small",
200
+ decision_model: "gpt-4o-mini",
201
+ max_memories_per_query: 20,
202
+ min_importance: 0.3,
203
+ min_similarity: 0.3,
204
+ auto_prune: true,
205
+ max_memories: 10000,
206
+ embedding_provider: "openai",
207
+ auto_extract_memories: features.memory ? true : null,
208
+ auto_ingest_files: true,
209
+ },
210
+ operator: {
211
+ enabled: features.operator,
212
+ virtual_browser: "http://localhost:8098",
213
+ display_width: 1024,
214
+ display_height: 768,
215
+ max_actions_per_turn: 5,
216
+ },
217
+ mcp: {
218
+ enabled: features.mcp,
219
+ base_url: "http://localhost:3000/mcp",
220
+ timeout: "30s",
221
+ retry_count: 3,
222
+ cache_ttl: "15m",
223
+ servers: mcpServers,
224
+ },
225
+ realtime: {
226
+ enabled: features.realtime,
227
+ provider: "openai",
228
+ model: "gpt-4o-realtime-preview",
229
+ voice: "alloy",
230
+ },
231
+ context: {
232
+ max_messages: 30,
233
+ max_tokens: 0,
234
+ keep_images: 5,
235
+ },
236
+ filesystem: {
237
+ enabled: true,
238
+ max_file_size: 10485760,
239
+ max_total_size: 104857600,
240
+ auto_extract: true,
241
+ auto_cleanup: true,
242
+ retention_days: 7,
243
+ },
244
+ telemetry: {
245
+ enabled: true,
246
+ endpoint: `http://localhost:${process.env.PORT || 4280}/api/telemetry`,
247
+ batch_size: 1,
248
+ flush_interval: 1, // Every 1 second
249
+ categories: [], // Empty = all categories
250
+ },
251
+ skills: {
252
+ enabled: skillDefinitions.length > 0,
253
+ definitions: skillDefinitions,
254
+ },
255
+ agents: (() => {
256
+ const multiAgentConfig = getMultiAgentConfig(features, agent.project_id);
257
+ const baseUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 4280}`;
258
+ return {
259
+ enabled: multiAgentConfig.enabled,
260
+ mode: multiAgentConfig.mode || "worker",
261
+ group: multiAgentConfig.group || agent.project_id || undefined,
262
+ // This agent's reachable URL for peer communication
263
+ url: `http://localhost:${agent.port}`,
264
+ // Discovery endpoint to find peer agents in the same group
265
+ discovery_url: `${baseUrl}/api/discovery/agents`,
266
+ };
267
+ })(),
268
+ };
269
+ }
270
+
271
+ // Push config to running agent (with authentication)
272
+ export async function pushConfigToAgent(agentId: string, port: number, config: any): Promise<{ success: boolean; error?: string }> {
273
+ try {
274
+ const res = await agentFetch(agentId, port, "/config", {
275
+ method: "POST",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: JSON.stringify(config),
278
+ signal: AbortSignal.timeout(5000),
279
+ });
280
+ if (res.ok) {
281
+ return { success: true };
282
+ }
283
+ const data = await res.json().catch(() => ({}));
284
+ return { success: false, error: data.error || `HTTP ${res.status}` };
285
+ } catch (err) {
286
+ return { success: false, error: String(err) };
287
+ }
288
+ }
289
+
290
+ // Push skills to running agent via /skills endpoint (not config)
291
+ export async function pushSkillsToAgent(agentId: string, port: number, skills: Array<{
292
+ name: string;
293
+ description: string;
294
+ instructions: string;
295
+ icon?: string;
296
+ category?: string;
297
+ tags?: string[];
298
+ tools?: string[];
299
+ enabled: boolean;
300
+ }>): Promise<{ success: boolean; error?: string }> {
301
+ if (skills.length === 0) {
302
+ return { success: true };
303
+ }
304
+
305
+ try {
306
+ // Push each skill - try PUT first (update), then POST (create) if not found
307
+ for (const skill of skills) {
308
+ // First try PUT to update existing skill
309
+ let res = await agentFetch(agentId, port, "/skills", {
310
+ method: "PUT",
311
+ headers: { "Content-Type": "application/json" },
312
+ body: JSON.stringify(skill),
313
+ signal: AbortSignal.timeout(5000),
314
+ });
315
+
316
+ // If skill doesn't exist (404), create it with POST
317
+ if (res.status === 404) {
318
+ res = await agentFetch(agentId, port, "/skills", {
319
+ method: "POST",
320
+ headers: { "Content-Type": "application/json" },
321
+ body: JSON.stringify(skill),
322
+ signal: AbortSignal.timeout(5000),
323
+ });
324
+ }
325
+
326
+ if (!res.ok) {
327
+ const data = await res.json().catch(() => ({}));
328
+ console.error(`[pushSkillsToAgent] Failed to push skill ${skill.name}:`, data.error || res.status);
329
+ }
330
+ }
331
+
332
+ // Enable skills globally via POST /skills/status
333
+ const statusRes = await agentFetch(agentId, port, "/skills/status", {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ enabled: true }),
337
+ signal: AbortSignal.timeout(5000),
338
+ });
339
+
340
+ if (!statusRes.ok) {
341
+ const data = await statusRes.json().catch(() => ({}));
342
+ return { success: false, error: data.error || `HTTP ${statusRes.status}` };
343
+ }
344
+
345
+ console.log(`[pushSkillsToAgent] Pushed ${skills.length} skill(s) to agent`);
346
+ return { success: true };
347
+ } catch (err) {
348
+ return { success: false, error: String(err) };
349
+ }
350
+ }
351
+
352
+ // Exported helper to start an agent process (used by API route and auto-restart)
353
+ export async function startAgentProcess(
354
+ agent: Agent,
355
+ options: { silent?: boolean; cleanData?: boolean } = {}
356
+ ): Promise<{ success: boolean; port?: number; error?: string }> {
357
+ const { silent = false, cleanData = false } = options;
358
+
359
+ // Check if binary exists
360
+ if (!binaryExists(BIN_DIR)) {
361
+ return { success: false, error: "Agent binary not available" };
362
+ }
363
+
364
+ // Check if already running (process map)
365
+ if (agentProcesses.has(agent.id)) {
366
+ return { success: false, error: "Agent already running" };
367
+ }
368
+
369
+ // Check if already being started (race condition prevention)
370
+ if (agentsStarting.has(agent.id)) {
371
+ return { success: false, error: "Agent is already starting" };
372
+ }
373
+
374
+ // Mark as starting
375
+ agentsStarting.add(agent.id);
376
+
377
+ // Get the API key for the agent's provider
378
+ const providerKey = ProviderKeys.getDecrypted(agent.provider);
379
+ if (!providerKey) {
380
+ agentsStarting.delete(agent.id);
381
+ return { success: false, error: `No API key for provider: ${agent.provider}` };
382
+ }
383
+
384
+ // Get provider config for env var name
385
+ const providerConfig = PROVIDERS[agent.provider as ProviderId];
386
+ if (!providerConfig) {
387
+ agentsStarting.delete(agent.id);
388
+ return { success: false, error: `Unknown provider: ${agent.provider}` };
389
+ }
390
+
391
+ // Use agent's permanently assigned port
392
+ const port = agent.port;
393
+ if (!port) {
394
+ agentsStarting.delete(agent.id);
395
+ return { success: false, error: "Agent has no assigned port" };
396
+ }
397
+
398
+ // Get or create API key for the agent
399
+ const agentApiKey = AgentDB.ensureApiKey(agent.id);
400
+ if (!agentApiKey) {
401
+ agentsStarting.delete(agent.id);
402
+ return { success: false, error: "Failed to get/create agent API key" };
403
+ }
404
+
405
+ try {
406
+ // Check if something is already running on this port (orphaned process)
407
+ try {
408
+ const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(500) });
409
+ if (res.ok) {
410
+ // Something is running - try to shut it down
411
+ if (!silent) {
412
+ console.log(` Port ${port} in use, stopping orphaned process...`);
413
+ }
414
+ try {
415
+ await fetch(`http://localhost:${port}/shutdown`, { method: "POST", signal: AbortSignal.timeout(1000) });
416
+ } catch {
417
+ // Shutdown failed - process might not support it
418
+ }
419
+ // Wait longer for port to be released
420
+ await new Promise(r => setTimeout(r, 1500));
421
+ }
422
+ } catch {
423
+ // No HTTP response - but port might still be bound by zombie process
424
+ }
425
+
426
+ // Double-check port is actually free by trying to connect
427
+ const isPortFree = await checkPortFree(port);
428
+ if (!isPortFree) {
429
+ if (!silent) {
430
+ console.log(` Port ${port} still in use, trying to kill process...`);
431
+ }
432
+ // Try to kill process using the port (Linux/Mac)
433
+ try {
434
+ const { execSync } = await import("child_process");
435
+ execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
436
+ await new Promise(r => setTimeout(r, 1000));
437
+ } catch {
438
+ // Ignore errors
439
+ }
440
+
441
+ // Final check
442
+ const stillInUse = !(await checkPortFree(port));
443
+ if (stillInUse) {
444
+ agentsStarting.delete(agent.id);
445
+ return { success: false, error: `Port ${port} is still in use` };
446
+ }
447
+ }
448
+
449
+ // Handle data directory
450
+ const agentDataDir = join(AGENTS_DATA_DIR, agent.id);
451
+ if (cleanData && existsSync(agentDataDir)) {
452
+ // Clean old data if requested
453
+ rmSync(agentDataDir, { recursive: true, force: true });
454
+ if (!silent) {
455
+ console.log(` Cleaned old data directory`);
456
+ }
457
+ }
458
+ if (!existsSync(agentDataDir)) {
459
+ mkdirSync(agentDataDir, { recursive: true });
460
+ }
461
+
462
+ if (!silent) {
463
+ console.log(`Starting agent ${agent.name} on port ${port}...`);
464
+ console.log(` Provider: ${agent.provider}`);
465
+ console.log(` Data dir: ${agentDataDir}`);
466
+ }
467
+
468
+ // Build environment with provider key and agent API key
469
+ // CONFIG_PATH ensures each agent has its own config file (prevents sharing)
470
+ const agentConfigPath = join(agentDataDir, "agent-config.json");
471
+ const env: Record<string, string> = {
472
+ ...process.env as Record<string, string>,
473
+ PORT: String(port),
474
+ DATA_DIR: agentDataDir,
475
+ CONFIG_PATH: agentConfigPath,
476
+ AGENT_API_KEY: agentApiKey,
477
+ [providerConfig.envVar]: providerKey,
478
+ };
479
+
480
+ // If memory is enabled and agent doesn't use OpenAI, also pass OpenAI key for embeddings
481
+ if (agent.features.memory && agent.provider !== "openai") {
482
+ const openaiKey = ProviderKeys.getDecrypted("openai");
483
+ if (openaiKey) {
484
+ env.OPENAI_API_KEY = openaiKey;
485
+ }
486
+ }
487
+
488
+ // Get binary path dynamically (allows hot-reload of new binary versions)
489
+ const binaryPath = getBinaryPathForAgent();
490
+
491
+ const proc = spawn({
492
+ cmd: [binaryPath],
493
+ env,
494
+ stdout: "inherit",
495
+ stderr: "inherit",
496
+ });
497
+
498
+ // Store process with port for tracking
499
+ agentProcesses.set(agent.id, { proc, port });
500
+
501
+ // Detect unexpected process exits (crashes)
502
+ proc.exited.then((code) => {
503
+ if (agentProcesses.has(agent.id)) {
504
+ agentProcesses.delete(agent.id);
505
+ setAgentStatus(agent.id, "stopped", code === 0 ? "exited" : "crashed");
506
+ }
507
+ });
508
+
509
+ // Wait for agent to be healthy
510
+ if (!silent) {
511
+ console.log(` Waiting for agent to be ready...`);
512
+ }
513
+ const isHealthy = await waitForAgentHealth(port);
514
+ if (!isHealthy) {
515
+ if (!silent) {
516
+ console.error(` Agent failed to start (health check timeout)`);
517
+ }
518
+ proc.kill();
519
+ agentProcesses.delete(agent.id);
520
+ agentsStarting.delete(agent.id);
521
+ return { success: false, error: "Health check timeout" };
522
+ }
523
+
524
+ // Push configuration to the agent
525
+ if (!silent) {
526
+ console.log(` Pushing configuration...`);
527
+ }
528
+ const config = buildAgentConfig(agent, providerKey);
529
+ const configResult = await pushConfigToAgent(agent.id, port, config);
530
+ if (!configResult.success) {
531
+ if (!silent) {
532
+ console.error(` Failed to configure agent: ${configResult.error}`);
533
+ }
534
+ // Agent is running but not configured - still usable but log warning
535
+ } else if (!silent) {
536
+ console.log(` Configuration applied successfully`);
537
+ }
538
+
539
+ // Push skills via /skills endpoint (separate from config)
540
+ if (config.skills?.definitions?.length > 0) {
541
+ const skillsResult = await pushSkillsToAgent(agent.id, port, config.skills.definitions);
542
+ if (!skillsResult.success && !silent) {
543
+ console.error(` Failed to push skills: ${skillsResult.error}`);
544
+ } else if (!silent) {
545
+ console.log(` Skills pushed successfully (${config.skills.definitions.length} skills)`);
546
+ }
547
+ }
548
+
549
+ // Update status in database + emit telemetry event
550
+ setAgentStatus(agent.id, "running");
551
+
552
+ if (!silent) {
553
+ console.log(`Agent ${agent.name} started on port ${port} (pid: ${proc.pid})`);
554
+ }
555
+
556
+ agentsStarting.delete(agent.id);
557
+ return { success: true, port };
558
+ } catch (err) {
559
+ agentsStarting.delete(agent.id);
560
+ if (!silent) {
561
+ console.error(`Failed to start agent: ${err}`);
562
+ }
563
+ return { success: false, error: String(err) };
564
+ }
565
+ }
566
+
567
+ // Transform DB agent to API response format (camelCase for frontend compatibility)
568
+ export function toApiAgent(agent: Agent) {
569
+ // Look up MCP server details
570
+ const mcpServerDetails = (agent.mcp_servers || [])
571
+ .map(id => McpServerDB.findById(id))
572
+ .filter((s): s is NonNullable<typeof s> => s !== null)
573
+ .map(s => ({
574
+ id: s.id,
575
+ name: s.name,
576
+ type: s.type,
577
+ status: s.status,
578
+ port: s.port,
579
+ url: s.url, // Include URL for HTTP servers
580
+ }));
581
+
582
+ // Look up skill details
583
+ const skillDetails = (agent.skills || [])
584
+ .map(id => SkillDB.findById(id))
585
+ .filter((s): s is NonNullable<typeof s> => s !== null)
586
+ .map(s => ({
587
+ id: s.id,
588
+ name: s.name,
589
+ description: s.description,
590
+ version: s.version,
591
+ enabled: s.enabled,
592
+ }));
593
+
594
+ return {
595
+ id: agent.id,
596
+ name: agent.name,
597
+ model: agent.model,
598
+ provider: agent.provider,
599
+ systemPrompt: agent.system_prompt,
600
+ status: agent.status,
601
+ port: agent.port,
602
+ features: agent.features,
603
+ mcpServers: agent.mcp_servers, // Keep IDs for backwards compatibility
604
+ mcpServerDetails, // Include full details
605
+ skills: agent.skills, // Skill IDs
606
+ skillDetails, // Include full details
607
+ projectId: agent.project_id,
608
+ createdAt: agent.created_at,
609
+ updatedAt: agent.updated_at,
610
+ };
611
+ }
612
+
613
+ // Transform DB project to API response format
614
+ export function toApiProject(project: Project) {
615
+ return {
616
+ id: project.id,
617
+ name: project.name,
618
+ description: project.description,
619
+ color: project.color,
620
+ createdAt: project.created_at,
621
+ updatedAt: project.updated_at,
622
+ };
623
+ }
624
+
625
+ // Helper to fetch from a running agent (with authentication)
626
+ export async function fetchFromAgent(agentId: string, port: number, endpoint: string): Promise<any> {
627
+ try {
628
+ const response = await agentFetch(agentId, port, endpoint, {
629
+ headers: { "Accept": "application/json" },
630
+ });
631
+ if (response.ok) {
632
+ return await response.json();
633
+ }
634
+ return null;
635
+ } catch {
636
+ return null;
637
+ }
638
+ }