Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/src/agents/base/BaseAgent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { VeniceClient, type AgentType } from '../../venice/index.js';
import { HeartbeatClient } from '../heartbeat.js';

export interface BaseAgentConfig {
veniceClient?: VeniceClient;
Expand All @@ -23,6 +24,7 @@ export abstract class BaseAgent {
protected readonly venice: VeniceClient;
protected readonly apiBaseUrl: string;
protected readonly agentId: string;
private readonly heartbeatClient: HeartbeatClient | null = null;

constructor(config: BaseAgentConfig = {}) {
if (config.veniceClient) {
Expand All @@ -39,6 +41,13 @@ export abstract class BaseAgent {
}
this.apiBaseUrl = config.apiBaseUrl ?? 'http://127.0.0.1:3001';
this.agentId = config.agentId ?? `${this.getCapability()}-agent-1`;

if (config.apiBaseUrl) {
this.heartbeatClient = new HeartbeatClient({
apiBaseUrl: this.apiBaseUrl,
agentId: this.agentId,
});
}
}

abstract execute(task: AgentTask): Promise<unknown | AgentError>;
Expand Down Expand Up @@ -85,6 +94,14 @@ export abstract class BaseAgent {
}
}

startHeartbeat(): void {
this.heartbeatClient?.start();
}

stopHeartbeat(): void {
this.heartbeatClient?.stop();
}

protected validateOutput(raw: unknown): unknown | null {
const result = this.getOutputSchema().safeParse(raw);
return result.success ? result.data : null;
Expand Down
51 changes: 51 additions & 0 deletions backend/src/agents/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export interface HeartbeatClientOptions {
apiBaseUrl: string;
agentId: string;
intervalMs?: number;
}

export class HeartbeatClient {
private readonly apiBaseUrl: string;
private readonly agentId: string;
private readonly intervalMs: number;
private interval: NodeJS.Timeout | null = null;
private stopped = false;

constructor(options: HeartbeatClientOptions) {
this.apiBaseUrl = options.apiBaseUrl.replace(/\/$/, '');
this.agentId = options.agentId;
this.intervalMs = options.intervalMs ?? 30_000;
}

start(): void {
if (this.interval) return;
this.stopped = false;
this.send();
this.interval = setInterval(() => {
this.send();
}, this.intervalMs);
}

stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
this.stopped = true;
}

private async send(): Promise<void> {
if (this.stopped) return;

try {
const response = await fetch(`${this.apiBaseUrl}/api/agents/${encodeURIComponent(this.agentId)}/heartbeat`, {
method: 'POST',
});
if (!response.ok) {
console.warn(`[Heartbeat] Heartbeat failed for ${this.agentId}: ${response.status}`);
}
} catch (err) {
console.warn(`[Heartbeat] Heartbeat error for ${this.agentId}:`, err instanceof Error ? err.message : 'unknown');
}
}
}
14 changes: 14 additions & 0 deletions backend/src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class AgentRegistry {
const registrations = this.agents.map(async ({ instance, capability }) => {
try {
await instance.register();
instance.startHeartbeat();
} catch (error) {
console.error(`[AgentRegistry] Failed to register ${capability} agent:`, error instanceof Error ? error.message : 'unknown');
}
Expand All @@ -61,6 +62,19 @@ export class AgentRegistry {
}
}

/**
* Stop heartbeats for all agents.
*/
async shutdown(): Promise<void> {
for (const { instance } of this.agents) {
try {
instance.stopHeartbeat();
} catch {
// ignore shutdown errors
}
}
}

/**
* Get all registered agents.
*/
Expand Down
16 changes: 15 additions & 1 deletion backend/src/api/routes/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,28 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router {
endpoint: data.endpoint,
stellarPublicKey: data.stellarPublicKey,
reputationScore: 0,
lastSeenAt: new Date().toISOString()
lastSeenAt: new Date().toISOString(),
status: 'online' as const
};

db.upsert(agent);

res.status(201).json(agent);
});

// POST /api/agents/:id/heartbeat
router.post("/:id/heartbeat", (req: Request, res: Response): void => {
const db = getDb();
const agent = db.findById(req.params.id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}

db.upsert({ ...agent, lastSeenAt: new Date().toISOString(), status: 'online' });
res.status(204).send();
});

// DELETE /api/agents/:id
router.delete("/:id", (req: Request, res: Response): void => {
const db = getDb();
Expand Down
4 changes: 3 additions & 1 deletion backend/src/coordinator/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,9 @@ export class Coordinator {
throw new Error(`No agent registry configured for type: ${agentType}`);
}

const agents = sortByCost(await this.agentRegistry.getAgents(agentType));
const agents = sortByCost(await this.agentRegistry.getAgents(agentType)).filter(
(agent) => agent.status === 'online'
);
if (agents.length === 0) {
throw new Error(`No agent registered for type: ${agentType}`);
}
Expand Down
24 changes: 18 additions & 6 deletions backend/src/db/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface AgentRecord {
stellarPublicKey: string;
reputationScore: number;
lastSeenAt: string;
status: 'online' | 'offline';
}

let _agentDb: Database.Database | null = null;
Expand All @@ -25,7 +26,8 @@ export function getAgentDb(dbPath?: string): Database.Database {
endpoint TEXT NOT NULL,
stellarPublicKey TEXT NOT NULL,
reputationScore REAL NOT NULL DEFAULT 0,
lastSeenAt TEXT NOT NULL
lastSeenAt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'online'
)
`);
}
Expand All @@ -40,23 +42,25 @@ export function closeAgentDb(): void {
export interface AgentDb {
upsert(agent: AgentRecord): void;
findById(id: string): AgentRecord | undefined;
list(filters?: { capability?: string; minReputation?: number; maxPriceXLM?: number }): AgentRecord[];
list(filters?: { capability?: string; minReputation?: number; maxPriceXLM?: number; status?: string }): AgentRecord[];
delete(id: string): void;
updateReputation(id: string, delta: number): void;
markOffline(olderThan: string): void;
}

export function createAgentDb(db: Database.Database): AgentDb {
return {
upsert(agent: AgentRecord): void {
db.prepare(`
INSERT INTO agents (id, capabilities, pricingXLM, endpoint, stellarPublicKey, reputationScore, lastSeenAt)
VALUES (@id, @capabilities, @pricingXLM, @endpoint, @stellarPublicKey, @reputationScore, @lastSeenAt)
INSERT INTO agents (id, capabilities, pricingXLM, endpoint, stellarPublicKey, reputationScore, lastSeenAt, status)
VALUES (@id, @capabilities, @pricingXLM, @endpoint, @stellarPublicKey, @reputationScore, @lastSeenAt, @status)
ON CONFLICT(id) DO UPDATE SET
capabilities = excluded.capabilities,
pricingXLM = excluded.pricingXLM,
endpoint = excluded.endpoint,
stellarPublicKey = excluded.stellarPublicKey,
lastSeenAt = excluded.lastSeenAt
lastSeenAt = excluded.lastSeenAt,
status = excluded.status
`).run({
...agent,
capabilities: JSON.stringify(agent.capabilities)
Expand All @@ -72,7 +76,7 @@ export function createAgentDb(db: Database.Database): AgentDb {
};
},

list(filters?: { capability?: string; minReputation?: number; maxPriceXLM?: number }): AgentRecord[] {
list(filters?: { capability?: string; minReputation?: number; maxPriceXLM?: number; status?: string }): AgentRecord[] {
let query = "SELECT * FROM agents WHERE 1=1";
const params: any[] = [];

Expand All @@ -88,6 +92,10 @@ export function createAgentDb(db: Database.Database): AgentDb {
query += " AND EXISTS (SELECT 1 FROM json_each(capabilities) WHERE value = ?)";
params.push(filters.capability);
}
if (filters?.status !== undefined) {
query += " AND status = ?";
params.push(filters.status);
}

const rows = db.prepare(query).all(...params) as any[];
return rows.map(row => ({
Expand All @@ -102,6 +110,10 @@ export function createAgentDb(db: Database.Database): AgentDb {

updateReputation(id: string, delta: number): void {
db.prepare("UPDATE agents SET reputationScore = reputationScore + ? WHERE id = ?").run(delta, id);
},

markOffline(olderThan: string): void {
db.prepare("UPDATE agents SET status = 'offline' WHERE lastSeenAt < ? AND status = 'online'").run(olderThan);
}
};
}
14 changes: 12 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
*/

import { createApp } from "./api/app";
import { initializeAgents } from "./agents";
import { startAgentSync } from "./registry/sync";
import { initializeAgents, globalAgentRegistry } from "./agents";
import { startAgentSync, stopAgentSync } from "./registry/sync";
import { loadConfig, getConfig } from "./config";
import { AgentCleanupService } from "./services/agentCleanup";

async function main() {
// ── Validate env config at startup ──────────────────────────────────────────
Expand All @@ -24,6 +25,10 @@ async function main() {
console.log("[ai-net-backend] Initializing agents...");
await initializeAgents();

// Start agent cleanup service
const cleanupService = new AgentCleanupService();
cleanupService.start();

// Create and start the server
const { httpServer } = createApp();

Expand All @@ -40,6 +45,7 @@ async function main() {
console.log(" - POST /api/agents/register - Register new agents");
console.log(" - GET /api/agents - List all agents");
console.log(" - GET /api/agents/capability/:type - Find agents by capability");
console.log(" - POST /api/agents/:id/heartbeat - Agent heartbeat");
});

// ── Graceful shutdown ──────────────────────────────────────────────────────
Expand All @@ -50,6 +56,10 @@ async function main() {
process.exit(1);
}, 10_000);

cleanupService.stop();
globalAgentRegistry.shutdown();
stopAgentSync();

httpServer.close(() => {
clearTimeout(timeout);
console.log("[ai-net-backend] Server closed.");
Expand Down
17 changes: 9 additions & 8 deletions backend/src/registry/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ export function startAgentSync(): void {

if (val && typeof val === "object" && val.id) {
db.upsert({
id: val.id,
capabilities: Array.isArray(val.capabilities) ? val.capabilities : (val.capabilities ? [val.capabilities] : []),
pricingXLM: Number(val.pricingXLM) || 0,
endpoint: val.endpoint || "",
stellarPublicKey: val.stellarPublicKey || "",
reputationScore: Number(val.reputationScore) || 0,
lastSeenAt: new Date().toISOString()
});
id: val.id,
capabilities: Array.isArray(val.capabilities) ? val.capabilities : (val.capabilities ? [val.capabilities] : []),
pricingXLM: Number(val.pricingXLM) || 0,
endpoint: val.endpoint || "",
stellarPublicKey: val.stellarPublicKey || "",
reputationScore: Number(val.reputationScore) || 0,
lastSeenAt: new Date().toISOString(),
status: 'online'
});
}
}
} catch (e) {
Expand Down
50 changes: 50 additions & 0 deletions backend/src/services/agentCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { getAgentDb, createAgentDb } from '../db/agents';
import { createLogger } from '../utils/logger';

export interface AgentCleanupOptions {
intervalMs?: number;
ttlMs?: number;
}

export class AgentCleanupService {
private readonly intervalMs: number;
private readonly ttlMs: number;
private interval: NodeJS.Timeout | null = null;
private stopped = false;
private readonly log = createLogger({ component: 'AgentCleanup' });

constructor(options: AgentCleanupOptions = {}) {
this.intervalMs = options.intervalMs ?? 60_000;
this.ttlMs = options.ttlMs ?? 90_000;
}

start(): void {
if (this.interval) return;
this.stopped = false;
this.tick();
this.interval = setInterval(() => {
this.tick();
}, this.intervalMs);
}

stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
this.stopped = true;
}

private tick(): void {
if (this.stopped) return;

try {
const cutoff = new Date(Date.now() - this.ttlMs).toISOString();
const db = createAgentDb(getAgentDb());
db.markOffline(cutoff);
this.log.info({ cutoff }, 'marked stale agents offline');
} catch (err) {
this.log.error({ err }, 'cleanup tick failed');
}
}
}
1 change: 1 addition & 0 deletions backend/src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface AgentRegistration {
type: string;
endpoint: string;
cost: number;
status: 'online' | 'offline';
}

export interface AgentRegistry {
Expand Down
Loading
Loading