Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
271 lines
7.6 KiB
JavaScript
271 lines
7.6 KiB
JavaScript
/**
|
|
* Shared tool-related utilities for AI agents routes.
|
|
* Extracted from shared.js for modular organization.
|
|
*/
|
|
|
|
import { dbAll } from '../../../database/connection.js';
|
|
import { AGENT_TOOLS } from '../../../services/AgentToolsService.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { safeParseJSON, normalizeToolList } from './shared.js';
|
|
|
|
export async function resolveAiToolsTableId(spaceId) {
|
|
const { dbGet } = await import('../../../database/connection.js');
|
|
|
|
if (spaceId) {
|
|
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 = 'AI Tools' OR ut.name = 'ai_tools')
|
|
LIMIT 1
|
|
`, [spaceId]);
|
|
if (table?.id) {
|
|
return table.id;
|
|
}
|
|
}
|
|
|
|
const fallback = await dbGet(`
|
|
SELECT ut.id
|
|
FROM universal_tables ut
|
|
WHERE ut.name = 'AI Tools' OR ut.name = 'ai_tools'
|
|
ORDER BY ut.id ASC
|
|
LIMIT 1
|
|
`);
|
|
return fallback?.id || null;
|
|
}
|
|
|
|
export async function resolveToolNames(agentConfig, spaceId) {
|
|
const toolEntries = normalizeToolList(agentConfig?.tools);
|
|
if (!toolEntries.length) {
|
|
return [];
|
|
}
|
|
|
|
const names = new Set();
|
|
const ids = [];
|
|
|
|
toolEntries.forEach((entry) => {
|
|
if (entry == null) {
|
|
return;
|
|
}
|
|
if (typeof entry === 'number') {
|
|
ids.push(entry);
|
|
return;
|
|
}
|
|
if (typeof entry === 'string') {
|
|
const trimmed = entry.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
if (/^\d+$/.test(trimmed)) {
|
|
ids.push(Number(trimmed));
|
|
return;
|
|
}
|
|
names.add(trimmed);
|
|
}
|
|
});
|
|
|
|
if (ids.length) {
|
|
const placeholders = ids.map(() => '?').join(', ');
|
|
|
|
const rows = await dbAll(
|
|
`SELECT tr.id, tr.data
|
|
FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE (ut.name LIKE '%Tools%' OR ut.name LIKE '%tools%')
|
|
AND tr.id IN (${placeholders})`,
|
|
ids
|
|
);
|
|
|
|
rows.forEach((row) => {
|
|
try {
|
|
const data = safeParseJSON(row.data, {});
|
|
const toolName = data?.name || data?.tool_name || data?.function_name;
|
|
if (toolName) {
|
|
names.add(toolName);
|
|
}
|
|
} catch {
|
|
// ignore malformed tool row
|
|
}
|
|
});
|
|
|
|
if (ids.length > 0 && names.size === 0) {
|
|
apiLogger.warn({ ids, spaceId }, 'Could not resolve tool IDs to names');
|
|
}
|
|
}
|
|
|
|
return Array.from(names).filter(Boolean);
|
|
}
|
|
|
|
export async function getAllowedTools(agentConfig, spaceId) {
|
|
const toolNames = await resolveToolNames(agentConfig, spaceId);
|
|
|
|
const baseConsultingTools = [
|
|
'get_workspace_info',
|
|
'query_table_data',
|
|
'get_table_schema',
|
|
'list_tables',
|
|
'analyze_table_data'
|
|
];
|
|
|
|
let tools;
|
|
if (!toolNames.length) {
|
|
tools = [...AGENT_TOOLS];
|
|
} else {
|
|
const allToolNames = new Set([...toolNames, ...baseConsultingTools]);
|
|
const filtered = AGENT_TOOLS.filter((tool) => tool?.function?.name && allToolNames.has(tool.function.name));
|
|
tools = filtered.length ? filtered : [...AGENT_TOOLS];
|
|
}
|
|
|
|
// ADR-113: Inject manage_plan tool when planning is enabled
|
|
const planningConfig = typeof agentConfig?.planning === 'object' && agentConfig.planning !== null
|
|
? agentConfig.planning
|
|
: {};
|
|
if (planningConfig.enabled) {
|
|
const maxTasks = Number(planningConfig.max_tasks) > 0 ? Number(planningConfig.max_tasks) : 20;
|
|
const threshold = Number(planningConfig.auto_plan_threshold) > 0
|
|
? Number(planningConfig.auto_plan_threshold)
|
|
: 3;
|
|
tools.push({
|
|
type: 'function',
|
|
function: {
|
|
name: 'manage_plan',
|
|
description: `Create or update a plan for the current conversation. Use this when a task requires ${threshold}+ steps. Maximum ${maxTasks} tasks per plan.`,
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
action: {
|
|
type: 'string',
|
|
enum: ['create', 'update_task', 'add_task', 'remove_task'],
|
|
description: 'Action to perform on the plan'
|
|
},
|
|
tasks: {
|
|
type: 'array',
|
|
description: 'Array of tasks (for create action). Each task: { id, title, status }',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', description: 'Task ID (sequential integer)' },
|
|
title: { type: 'string', description: 'Short task title' },
|
|
status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'blocked'], description: 'Task status' },
|
|
note: { type: 'string', description: 'Optional note (brief completion note or blocker reason)' }
|
|
},
|
|
required: ['id', 'title', 'status']
|
|
}
|
|
},
|
|
task_id: {
|
|
type: 'number',
|
|
description: 'Task ID to update or remove (for update_task/remove_task actions)'
|
|
},
|
|
status: {
|
|
type: 'string',
|
|
enum: ['pending', 'in_progress', 'completed', 'blocked'],
|
|
description: 'New status (for update_task action)'
|
|
},
|
|
note: {
|
|
type: 'string',
|
|
description: 'Optional note to attach to the task (for update_task action)'
|
|
},
|
|
title: {
|
|
type: 'string',
|
|
description: 'Task title (for add_task action)'
|
|
}
|
|
},
|
|
required: ['action']
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
/**
|
|
* Auto-inject context into tool arguments
|
|
*/
|
|
export function injectToolContext(toolName, args, context) {
|
|
const { spaceId, userId } = context;
|
|
const result = { ...args };
|
|
|
|
const needsSpaceId = [
|
|
'get_workspace_info',
|
|
'create_dashboard'
|
|
];
|
|
|
|
const canUseSpaceId = [
|
|
'list_tables'
|
|
];
|
|
|
|
if (needsSpaceId.includes(toolName) && !result.space_id && spaceId) {
|
|
result.space_id = spaceId;
|
|
}
|
|
|
|
if (canUseSpaceId.includes(toolName) && !result.project_id && !result.space_id && spaceId) {
|
|
result.space_id = spaceId;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function toAnthropicTools(tools) {
|
|
return (tools || [])
|
|
.map((tool) => {
|
|
const fn = tool?.function;
|
|
if (!fn?.name) {
|
|
return null;
|
|
}
|
|
return {
|
|
name: fn.name,
|
|
description: fn.description || '',
|
|
input_schema: fn.parameters || { type: 'object', properties: {} }
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function getAnthropicText(content) {
|
|
if (!content) {
|
|
return '';
|
|
}
|
|
if (typeof content === 'string') {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((item) => item?.type === 'text' && item?.text)
|
|
.map((item) => item.text)
|
|
.join('');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function truncateArray(value, limit, label) {
|
|
if (!Array.isArray(value) || value.length <= limit) {
|
|
return value;
|
|
}
|
|
const trimmed = value.slice(0, limit);
|
|
trimmed.push({ _truncated: true, total: value.length, label });
|
|
return trimmed;
|
|
}
|
|
|
|
export function sanitizeToolResult(result) {
|
|
if (!result || typeof result !== 'object') {
|
|
return result;
|
|
}
|
|
const cloned = safeParseJSON(JSON.stringify(result));
|
|
if (!cloned || typeof cloned !== 'object') {
|
|
return result;
|
|
}
|
|
if (Array.isArray(cloned.tables)) {
|
|
cloned.tables = truncateArray(cloned.tables, 120, 'tables');
|
|
}
|
|
if (Array.isArray(cloned.rows)) {
|
|
cloned.rows = truncateArray(cloned.rows, 50, 'rows');
|
|
}
|
|
if (Array.isArray(cloned.projects)) {
|
|
cloned.projects = truncateArray(cloned.projects, 50, 'projects');
|
|
}
|
|
if (Array.isArray(cloned.widgets)) {
|
|
cloned.widgets = truncateArray(cloned.widgets, 50, 'widgets');
|
|
}
|
|
return cloned;
|
|
}
|