Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
443 lines
16 KiB
JavaScript
443 lines
16 KiB
JavaScript
/**
|
|
* Agent CRUD Controller
|
|
* GET /agents, GET /agents/:spaceId, POST /agents/search
|
|
* PUT /agents/:agentId
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import { dbGet, dbAll, dbRun, isPostgres } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, notFound, badRequest, error } from '../../../utils/response.js';
|
|
import {
|
|
safeParseJSON,
|
|
resolveAgentRelations,
|
|
deepMergeContextSettings,
|
|
} from './shared.js';
|
|
import { resolveEmbeddingConfig, generateEmbedding } from './sharedEmbedding.js';
|
|
import { resolveModel, resolveProvider, parseAgentRow } from './sharedAgentResolution.js';
|
|
|
|
const router = Router();
|
|
|
|
/**
|
|
* ADR-0079 P4: flip Tier-B `visibility: 'locked'` → `'unlocked'` for the agents
|
|
* the user has unlocked via promo (MASTERMIND / MESHOK) stored in
|
|
* `users.agent_config.unlocked_agent_slugs`. Mutates `agents` in place.
|
|
* Best-effort — never throws.
|
|
*/
|
|
async function applyUnlockedAgentVisibility(agents, userId) {
|
|
if (!userId || !Array.isArray(agents) || agents.length === 0) return;
|
|
try {
|
|
const row = await dbGet('SELECT agent_config FROM users WHERE id = ?', [userId]);
|
|
if (!row) return;
|
|
const cfg = typeof row.agent_config === 'string'
|
|
? safeParseJSON(row.agent_config, {})
|
|
: (row.agent_config || {});
|
|
const unlocked = Array.isArray(cfg.unlocked_agent_slugs) ? cfg.unlocked_agent_slugs : [];
|
|
if (unlocked.length === 0) return;
|
|
for (const a of agents) {
|
|
if (a.visibility === 'locked' && a.agent_slug && unlocked.includes(a.agent_slug)) {
|
|
a.visibility = 'unlocked';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
apiLogger.warn({ err, userId }, '[ADR-0079] applyUnlockedAgentVisibility failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/v3/ai/agents
|
|
* Get all AI agents across all spaces for current user
|
|
*/
|
|
router.get('/agents', async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
|
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
|
|
const tables = await dbAll(`
|
|
SELECT ut.id as table_id, s.id as space_id, s.name as space_name
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
JOIN spaces s ON p.space_id = s.id
|
|
WHERE (ut.name LIKE '%Agents%' OR ut.name LIKE '%agents%' OR ut.name LIKE '%Агенты%')
|
|
AND ut.name NOT LIKE 'doc\\_%' ESCAPE '\\'
|
|
AND (s.owner_id = ? OR s.type = 'admin')
|
|
`, [userId]);
|
|
|
|
if (!tables || tables.length === 0) {
|
|
return success(res, { agents: [], total: 0, limit, offset });
|
|
}
|
|
|
|
// Count total across all agent tables
|
|
let total = 0;
|
|
for (const table of tables) {
|
|
const countResult = await dbGet(
|
|
'SELECT COUNT(*) as cnt FROM table_rows WHERE table_id = ?',
|
|
[table.table_id]
|
|
);
|
|
total += countResult?.cnt || 0;
|
|
}
|
|
|
|
// Fetch rows with pagination across tables
|
|
const allAgents = [];
|
|
let skipped = 0;
|
|
let collected = 0;
|
|
|
|
for (const table of tables) {
|
|
if (collected >= limit) break;
|
|
|
|
const tableCount = (await dbGet(
|
|
'SELECT COUNT(*) as cnt FROM table_rows WHERE table_id = ?',
|
|
[table.table_id]
|
|
))?.cnt || 0;
|
|
|
|
// Skip entire table if offset hasn't been reached yet
|
|
if (skipped + tableCount <= offset) {
|
|
skipped += tableCount;
|
|
continue;
|
|
}
|
|
|
|
const tableOffset = Math.max(offset - skipped, 0);
|
|
const tableLimit = limit - collected;
|
|
|
|
const rows = await dbAll(`
|
|
SELECT id, data, created_at FROM table_rows
|
|
WHERE table_id = ? ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`, [table.table_id, tableLimit, tableOffset]);
|
|
|
|
skipped += tableOffset;
|
|
|
|
for (const row of rows) {
|
|
if (collected >= limit) break;
|
|
try {
|
|
const data = safeParseJSON(row.data, {});
|
|
if (!data || typeof data !== 'object') continue;
|
|
await resolveAgentRelations(data, table.table_id);
|
|
|
|
const resolvedModel = await resolveModel(data.model);
|
|
const resolvedProvider = await resolveProvider(data.provider_id || data.operator_id);
|
|
|
|
allAgents.push(parseAgentRow(row, data, {
|
|
table_id: table.table_id,
|
|
model: resolvedModel.model_id,
|
|
model_name: resolvedModel.name,
|
|
model_id: resolvedModel.id,
|
|
provider: resolvedProvider.api_identifier,
|
|
provider_name: resolvedProvider.name,
|
|
provider_id: resolvedProvider.id,
|
|
space_id: table.space_id,
|
|
space_name: table.space_name,
|
|
}));
|
|
collected++;
|
|
} catch (e) {
|
|
apiLogger.warn({ err: e }, 'Failed to parse agent data');
|
|
}
|
|
}
|
|
}
|
|
|
|
await applyUnlockedAgentVisibility(allAgents, userId);
|
|
return success(res, { agents: allAgents, total, limit, offset });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error fetching all AI agents');
|
|
return error(res, 'FETCH_AGENTS_ERROR', 'Failed to fetch agents', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/ai/agents/:spaceId
|
|
* Get all AI agents for a space
|
|
*/
|
|
router.get('/agents/:spaceId', async (req, res) => {
|
|
try {
|
|
const { spaceId } = req.params;
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
|
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
|
|
const table = await dbGet(`
|
|
SELECT ut.id,
|
|
(SELECT COUNT(*) FROM table_rows tr WHERE tr.table_id = ut.id) as row_count
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE p.space_id = ? AND (ut.name LIKE '%Agents%' OR ut.name LIKE '%agents%' OR ut.name LIKE '%Агенты%')
|
|
AND ut.name NOT LIKE 'doc\\_%' ESCAPE '\\'
|
|
ORDER BY row_count DESC
|
|
LIMIT 1
|
|
`, [spaceId]);
|
|
|
|
if (!table) {
|
|
return success(res, { agents: [], total: 0, limit, offset });
|
|
}
|
|
|
|
const total = table.row_count || 0;
|
|
|
|
const rows = await dbAll(`
|
|
SELECT id, data, created_at FROM table_rows
|
|
WHERE table_id = ? ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`, [table.id, limit, offset]);
|
|
|
|
const agents = [];
|
|
for (const row of rows) {
|
|
const data = safeParseJSON(row.data, {});
|
|
if (!data || typeof data !== 'object') continue;
|
|
|
|
const resolvedModel = await resolveModel(data.model);
|
|
const resolvedProvider = await resolveProvider(data.provider_id || data.operator_id);
|
|
|
|
agents.push(parseAgentRow(row, data, {
|
|
table_id: table.id,
|
|
model: resolvedModel.model_id,
|
|
model_name: resolvedModel.name,
|
|
model_id: resolvedModel.id,
|
|
provider: resolvedProvider.api_identifier,
|
|
provider_name: resolvedProvider.name,
|
|
provider_id: resolvedProvider.id,
|
|
}));
|
|
}
|
|
|
|
await applyUnlockedAgentVisibility(agents, req.user?.id);
|
|
return success(res, { agents, total, limit, offset });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error fetching AI agents');
|
|
return error(res, 'FETCH_AGENTS_ERROR', 'Failed to fetch agents', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/ai/agents/search
|
|
* Semantic search for AI agents using vector embeddings
|
|
*/
|
|
router.post('/agents/search', async (req, res) => {
|
|
try {
|
|
const { query, spaceId, limit = 10 } = req.body;
|
|
|
|
if (!query || !spaceId) {
|
|
return badRequest(res, 'Query and spaceId are required');
|
|
}
|
|
|
|
const embeddingConfig = await resolveEmbeddingConfig(null, spaceId);
|
|
|
|
if (!embeddingConfig.apiKey) {
|
|
return badRequest(res, 'No embedding API key configured. Use text search instead.');
|
|
}
|
|
|
|
const queryEmbedding = await generateEmbedding(query, embeddingConfig.apiKey, embeddingConfig.model, embeddingConfig.baseUrl);
|
|
|
|
const table = await dbGet(`
|
|
SELECT ut.id
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE p.space_id = ? AND (ut.name LIKE '%Agents%' OR ut.name LIKE '%agents%' OR ut.name LIKE '%Агенты%')
|
|
LIMIT 1
|
|
`, [spaceId]);
|
|
|
|
if (!table) {
|
|
return success(res, { agents: [], message: 'No agents table found' });
|
|
}
|
|
|
|
const rows = await dbAll(`
|
|
SELECT id, data, created_at FROM table_rows WHERE table_id = ?
|
|
`, [table.id]);
|
|
|
|
const agentsWithScores = [];
|
|
|
|
for (const row of rows) {
|
|
const data = safeParseJSON(row.data, {});
|
|
if (!data || typeof data !== 'object') continue;
|
|
|
|
const searchableText = [
|
|
data.name || '',
|
|
data.description || '',
|
|
data.system_prompt ? data.system_prompt.substring(0, 500) : ''
|
|
].filter(Boolean).join(' | ');
|
|
|
|
if (!searchableText.trim()) continue;
|
|
|
|
try {
|
|
const agentEmbedding = await generateEmbedding(searchableText, embeddingConfig.apiKey, embeddingConfig.model, embeddingConfig.baseUrl);
|
|
|
|
let dotProduct = 0, normA = 0, normB = 0;
|
|
for (let i = 0; i < queryEmbedding.length; i++) {
|
|
dotProduct += queryEmbedding[i] * agentEmbedding[i];
|
|
normA += queryEmbedding[i] * queryEmbedding[i];
|
|
normB += agentEmbedding[i] * agentEmbedding[i];
|
|
}
|
|
const similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
|
|
const isActive = data.is_active === true || data.is_active === 1 || data.is_active === 'true' || data.status === 'active';
|
|
|
|
agentsWithScores.push({
|
|
id: row.id, name: data.name || 'Unnamed Agent',
|
|
description: data.description || '', system_prompt: data.system_prompt || '',
|
|
icon: data.icon || '🤖', is_active: isActive,
|
|
response_mode: data.response_mode || 'mention_only', similarity
|
|
});
|
|
} catch (embError) {
|
|
apiLogger.warn({ err: embError, agentId: row.id }, 'Failed to generate embedding for agent');
|
|
}
|
|
}
|
|
|
|
agentsWithScores.sort((a, b) => b.similarity - a.similarity);
|
|
|
|
return success(res, {
|
|
agents: agentsWithScores.slice(0, parseInt(limit)),
|
|
query, model: embeddingConfig.model
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error in semantic agent search');
|
|
return error(res, 'SEMANTIC_SEARCH_ERROR', 'Semantic search failed', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/ai/agents/:spaceId/:agentId/unlock
|
|
* ADR-0079 §2.3 — Settings → Add Agent path: stash the agent's slug in
|
|
* `users.agent_config.unlocked_agent_slugs` so subsequent GET /agents calls
|
|
* surface it as `visibility='unlocked'` via applyUnlockedAgentVisibility().
|
|
* Companion to /auth/register MASTERMIND / MESHOK promo (StarterPackService.applyPromoUnlock).
|
|
*
|
|
* Idempotent — duplicate unlocks deduplicate the slug list.
|
|
*/
|
|
router.post('/agents/:spaceId/:agentId/unlock', async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return error(res, 'UNAUTHENTICATED', 'login required', 401);
|
|
|
|
const { agentId } = req.params;
|
|
const row = await dbGet(`
|
|
SELECT tr.id, tr.data
|
|
FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE tr.id = ? AND (ut.name LIKE '%Agents%' OR ut.name LIKE '%agents%')
|
|
`, [agentId]);
|
|
if (!row) return notFound(res, 'Agent');
|
|
|
|
const data = safeParseJSON(row.data, {});
|
|
const slug = data.agent_slug || data.slug;
|
|
if (!slug) return badRequest(res, 'Agent has no slug to unlock');
|
|
|
|
const userRow = await dbGet('SELECT agent_config FROM users WHERE id = ?', [userId]);
|
|
const cfg = userRow && (typeof userRow.agent_config === 'string'
|
|
? safeParseJSON(userRow.agent_config, {})
|
|
: (userRow.agent_config || {}));
|
|
const current = Array.isArray(cfg?.unlocked_agent_slugs) ? cfg.unlocked_agent_slugs : [];
|
|
if (current.includes(slug)) {
|
|
return success(res, { slug, already: true, unlocked_agent_slugs: current });
|
|
}
|
|
const next = [...current, slug];
|
|
|
|
await dbRun(
|
|
`UPDATE users
|
|
SET agent_config = jsonb_set(
|
|
COALESCE(agent_config, '{}'::jsonb),
|
|
'{unlocked_agent_slugs}',
|
|
to_jsonb(?::text[]),
|
|
true
|
|
),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`,
|
|
[next, userId]
|
|
);
|
|
|
|
apiLogger.info({ userId, slug }, '[ADR-0079] agent unlocked via Settings → Add Agent');
|
|
return success(res, { slug, already: false, unlocked_agent_slugs: next });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error unlocking agent');
|
|
return error(res, 'UNLOCK_AGENT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/ai/agents/:agentId
|
|
* Update agent settings
|
|
* ADR-110: context_settings uses deep merge
|
|
*/
|
|
router.put('/agents/:agentId', async (req, res) => {
|
|
try {
|
|
const { agentId } = req.params;
|
|
const { provider_id, operator_id, model, system_prompt, crm_instructions, main_instructions, name, description, icon, is_active, response_mode, invocation_mode, context_settings } = req.body;
|
|
const effectiveProviderId = provider_id || operator_id;
|
|
|
|
const row = await dbGet(`
|
|
SELECT tr.id, tr.data, tr.table_id
|
|
FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE tr.id = ? AND (ut.name LIKE '%Agents%' OR ut.name LIKE '%agents%')
|
|
`, [agentId]);
|
|
|
|
if (!row) return notFound(res, 'Agent');
|
|
|
|
const data = safeParseJSON(row.data, {});
|
|
|
|
if (effectiveProviderId !== undefined) data.operator_id = effectiveProviderId;
|
|
if (model !== undefined) data.model = model;
|
|
if (system_prompt !== undefined) data.system_prompt = system_prompt;
|
|
if (crm_instructions !== undefined) data.crm_instructions = crm_instructions;
|
|
if (main_instructions !== undefined) data.main_instructions = main_instructions;
|
|
if (name !== undefined) data.name = name;
|
|
if (description !== undefined) data.description = description;
|
|
if (icon !== undefined) data.icon = icon;
|
|
if (is_active !== undefined) data.is_active = is_active;
|
|
if (response_mode !== undefined) data.response_mode = response_mode;
|
|
if (invocation_mode !== undefined) {
|
|
const allowed = ['mention', 'command', 'both'];
|
|
if (invocation_mode === null || invocation_mode === '') {
|
|
data.invocation_mode = null;
|
|
} else if (allowed.includes(invocation_mode)) {
|
|
data.invocation_mode = invocation_mode;
|
|
} else {
|
|
return badRequest(res, `invocation_mode must be one of: ${allowed.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// ADR-110: Deep merge context_settings
|
|
if (context_settings !== undefined) {
|
|
const incoming = typeof context_settings === 'string'
|
|
? safeParseJSON(context_settings, {}) : (context_settings || {});
|
|
const existing = safeParseJSON(data.context_settings, {});
|
|
data.context_settings = deepMergeContextSettings(existing, incoming);
|
|
}
|
|
|
|
// Update relation column values
|
|
const columns = await dbAll(
|
|
'SELECT id, column_name, config FROM table_columns WHERE table_id = ?',
|
|
[row.table_id]
|
|
);
|
|
for (const col of columns) {
|
|
const colConfig = safeParseJSON(col.config, {});
|
|
if (!colConfig.relation?.enabled) continue;
|
|
if (col.column_name === 'operator_id' && effectiveProviderId !== undefined) {
|
|
data[String(col.id)] = String(effectiveProviderId);
|
|
} else if (col.column_name === 'model' && model !== undefined) {
|
|
const modelRow = await dbGet(isPostgres()
|
|
? `SELECT tr.id FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE (ut.name = 'AI Models' OR ut.name = 'ai_models')
|
|
AND tr.data->>'model_id' = $1
|
|
LIMIT 1`
|
|
: `SELECT tr.id FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE (ut.name = 'AI Models' OR ut.name = 'ai_models')
|
|
AND json_extract(tr.data, '$.model_id') = ?
|
|
LIMIT 1`,
|
|
[model]);
|
|
if (modelRow) {
|
|
data[String(col.id)] = String(modelRow.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
await dbRun(
|
|
'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?',
|
|
[JSON.stringify(data), new Date().toISOString(), agentId]
|
|
);
|
|
|
|
return success(res, { id: Number(agentId), ...data });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error updating agent');
|
|
return error(res, 'UPDATE_AGENT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|