sync: Agent Architecture Overhaul - AI SDK 6 + workflow optimization
Synced from private repo (feature/ai-sdk-6-upgrade): - Upgrade AI SDK 5 → 6 (packages + API changes) - Add sqliteQuery tool for flexible read-only queries - New WorkflowExecutor with workflow-specific tools - 83% token reduction for workflow execution - Remove mini-rah dead code (simplified architecture) - Add context management usage endpoint - Fix Connect workflow instructions - Clickable node references in workflow UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
f9271aeeb4
commit
3f0426ecf4
@@ -42,12 +42,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryDimensionNodes: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Delegation and reasoning (orchestrator only)
|
||||
// Orchestration: Workflows and reasoning (orchestrator only)
|
||||
webSearch: 'orchestration',
|
||||
think: 'orchestration',
|
||||
delegateToMiniRAH: 'orchestration',
|
||||
delegateNodeQuotes: 'orchestration',
|
||||
delegateNodeComparison: 'orchestration',
|
||||
delegateToWiseRAH: 'orchestration',
|
||||
executeWorkflow: 'orchestration',
|
||||
listWorkflows: 'orchestration',
|
||||
|
||||
@@ -9,8 +9,7 @@ import { updateEdgeTool } from '../database/updateEdge';
|
||||
import { quickLinkTool } from '../database/quickLink';
|
||||
import { createDimensionTool } from '../database/createDimension';
|
||||
import { updateDimensionTool } from '../database/updateDimension';
|
||||
import { lockDimensionTool } from '../database/lockDimension';
|
||||
import { unlockDimensionTool } from '../database/unlockDimension';
|
||||
// lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
|
||||
import { deleteDimensionTool } from '../database/deleteDimension';
|
||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||
import { getDimensionTool } from '../database/getDimension';
|
||||
@@ -18,8 +17,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
import { delegateToMiniRAHTool } from '../orchestration/delegateToMiniRAH';
|
||||
import { delegateNodeQuotesTool, delegateNodeComparisonTool } from '../orchestration/delegationHelpers';
|
||||
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
|
||||
import { executeWorkflowTool } from '../orchestration/executeWorkflow';
|
||||
import { listWorkflowsTool } from '../orchestration/listWorkflows';
|
||||
@@ -28,10 +25,12 @@ import { editWorkflowTool } from '../orchestration/editWorkflow';
|
||||
import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
import { sqliteQueryTool } from '../other/sqliteQuery';
|
||||
import { logEvalToolCall } from '@/services/evals/evalsLogger';
|
||||
|
||||
// Core tools available to all agents (read-only graph operations)
|
||||
const CORE_TOOLS: Record<string, any> = {
|
||||
sqliteQuery: sqliteQueryTool,
|
||||
queryNodes: queryNodesTool,
|
||||
getNodesById: getNodesByIdTool,
|
||||
queryEdge: queryEdgeTool,
|
||||
@@ -44,9 +43,6 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||
webSearch: webSearchTool,
|
||||
think: thinkTool,
|
||||
delegateToMiniRAH: delegateToMiniRAHTool,
|
||||
delegateNodeQuotes: delegateNodeQuotesTool,
|
||||
delegateNodeComparison: delegateNodeComparisonTool,
|
||||
delegateToWiseRAH: delegateToWiseRAHTool,
|
||||
executeWorkflow: executeWorkflowTool,
|
||||
listWorkflows: listWorkflowsTool,
|
||||
@@ -63,8 +59,6 @@ const EXECUTION_TOOLS: Record<string, any> = {
|
||||
quickLink: quickLinkTool,
|
||||
createDimension: createDimensionTool,
|
||||
updateDimension: updateDimensionTool,
|
||||
lockDimension: lockDimensionTool,
|
||||
unlockDimension: unlockDimensionTool,
|
||||
deleteDimension: deleteDimensionTool,
|
||||
youtubeExtract: youtubeExtractTool,
|
||||
websiteExtract: websiteExtractTool,
|
||||
@@ -98,8 +92,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
'quickLink',
|
||||
'createDimension',
|
||||
'updateDimension',
|
||||
'lockDimension',
|
||||
'unlockDimension',
|
||||
'deleteDimension',
|
||||
'youtubeExtract',
|
||||
'websiteExtract',
|
||||
@@ -108,25 +100,22 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
|
||||
const EXECUTOR_TOOL_NAMES = [
|
||||
...Object.keys(CORE_TOOLS),
|
||||
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
|
||||
name !== 'delegateToMiniRAH' &&
|
||||
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
|
||||
name !== 'delegateToWiseRAH' &&
|
||||
name !== 'executeWorkflow' &&
|
||||
name !== 'delegateNodeQuotes' &&
|
||||
name !== 'delegateNodeComparison'
|
||||
name !== 'executeWorkflow'
|
||||
),
|
||||
...Object.keys(EXECUTION_TOOLS),
|
||||
];
|
||||
|
||||
// Note: PLANNER_TOOL_NAMES kept for backwards compatibility but workflows now use specific tool sets
|
||||
const PLANNER_TOOL_NAMES = [
|
||||
...Object.keys(CORE_TOOLS),
|
||||
'webSearch',
|
||||
'think',
|
||||
'delegateToMiniRAH',
|
||||
'updateNode', // For workflow execution (integrate workflow needs direct write access)
|
||||
'createEdge', // For edge creation in workflows
|
||||
'quickLink', // Fast edge creation via text search
|
||||
'updateDimension', // For survey workflow (dimension analysis)
|
||||
'updateNode',
|
||||
'createEdge',
|
||||
'quickLink',
|
||||
'updateDimension',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -196,6 +185,17 @@ export function getToolsForRole(role: 'orchestrator' | 'executor' | 'planner'):
|
||||
return getHelperTools(names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools by their names (for workflow execution with specific tool sets)
|
||||
*/
|
||||
export function getToolsByNames(toolNames: string[]): Record<string, any> {
|
||||
if (!Array.isArray(toolNames) || toolNames.length === 0) {
|
||||
console.warn('[getToolsByNames] No tool names provided');
|
||||
return {};
|
||||
}
|
||||
return getHelperTools(toolNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool with given parameters and context
|
||||
*/
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { MiniRAHExecutor } from '@/services/agents/executor';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
import type { Node } from '@/types/database';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
|
||||
type CapsuleNodeRole = 'primary' | 'secondary' | 'referenced';
|
||||
|
||||
interface CapsuleNodeSnapshot {
|
||||
id: number;
|
||||
title: string | null;
|
||||
link: string | null;
|
||||
dimensions: string[];
|
||||
chunkStatus: string;
|
||||
hasChunks: boolean;
|
||||
excerpt: string | null;
|
||||
role: CapsuleNodeRole;
|
||||
}
|
||||
|
||||
interface DelegationCapsule {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
primary: CapsuleNodeSnapshot | null;
|
||||
secondary: CapsuleNodeSnapshot[];
|
||||
referenced: CapsuleNodeSnapshot[];
|
||||
focusCount: number;
|
||||
}
|
||||
|
||||
function truncateWords(text: string, limit: number): string {
|
||||
if (!text) return '';
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words.length <= limit) return text.trim();
|
||||
return `${words.slice(0, limit).join(' ')}…`;
|
||||
}
|
||||
|
||||
function describeChunk(node: Node): { status: string; hasChunks: boolean } {
|
||||
const status = node.chunk_status || 'unknown';
|
||||
const hasChunks = status === 'chunked' || (typeof node.chunk === 'string' && node.chunk.length > 0);
|
||||
return { status, hasChunks };
|
||||
}
|
||||
|
||||
function buildSnapshot(node: Node, role: CapsuleNodeRole): CapsuleNodeSnapshot {
|
||||
const primaryText = (node.content || node.description || '').trim();
|
||||
const linkFallback = node.link || '';
|
||||
const excerptSource = primaryText.length > 0 ? primaryText : linkFallback;
|
||||
const { status, hasChunks } = describeChunk(node);
|
||||
return {
|
||||
id: node.id,
|
||||
title: node.title || null,
|
||||
link: node.link || null,
|
||||
dimensions: node.dimensions || [],
|
||||
chunkStatus: status,
|
||||
hasChunks,
|
||||
excerpt: excerptSource ? truncateWords(excerptSource, 80) : null,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCapsuleForLLM(capsule: DelegationCapsule): string {
|
||||
const lines: string[] = ['=== DELEGATION CAPSULE ==='];
|
||||
if (capsule.primary) {
|
||||
lines.push('Primary focus:', formatSnapshotLine(capsule.primary));
|
||||
} else {
|
||||
lines.push('Primary focus: None');
|
||||
}
|
||||
|
||||
if (capsule.secondary.length > 0) {
|
||||
lines.push('Secondary focus nodes:');
|
||||
capsule.secondary.forEach(snapshot => {
|
||||
lines.push(`- ${formatSnapshotLine(snapshot)}`);
|
||||
});
|
||||
} else {
|
||||
lines.push('Secondary focus nodes: None');
|
||||
}
|
||||
|
||||
if (capsule.referenced.length > 0) {
|
||||
lines.push('Referenced nodes provided in this task:');
|
||||
capsule.referenced.forEach(snapshot => {
|
||||
lines.push(`- ${formatSnapshotLine(snapshot)}`);
|
||||
});
|
||||
}
|
||||
|
||||
lines.push('Instructions:',
|
||||
'- Use the capsule snapshots as ground truth for node IDs and titles.',
|
||||
'- Call getNodesById if you need the full record beyond the provided excerpt.',
|
||||
'- List the node IDs you used in the "Context sources used" line of your final summary.',
|
||||
'=== END CAPSULE ===');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatSnapshotLine(snapshot: CapsuleNodeSnapshot): string {
|
||||
const dimensionLabel = snapshot.dimensions.length > 0 ? snapshot.dimensions.join(', ') : 'none';
|
||||
const excerpt = snapshot.excerpt ? `Excerpt: ${snapshot.excerpt}` : 'Excerpt: (no stored content; hydrate via getNodesById if required)';
|
||||
return `[NODE:${snapshot.id}:"${snapshot.title || 'Untitled'}"] | role=${snapshot.role} | dimensions=${dimensionLabel} | chunk_status=${snapshot.chunkStatus} | ${excerpt}`;
|
||||
}
|
||||
|
||||
function buildSourceBlock(snapshot: CapsuleNodeSnapshot): string {
|
||||
return [
|
||||
`=== SOURCE: NODE ${snapshot.id} ===`,
|
||||
`Title: "${snapshot.title || 'Untitled'}"`,
|
||||
`Role: ${snapshot.role}`,
|
||||
`Chunk status: ${snapshot.chunkStatus} (has_chunks=${snapshot.hasChunks})`,
|
||||
snapshot.link ? `Link: ${snapshot.link}` : 'Link: None',
|
||||
snapshot.dimensions.length > 0 ? `Dimensions: ${snapshot.dimensions.join(', ')}` : 'Dimensions: None',
|
||||
snapshot.excerpt ? `Excerpt: ${snapshot.excerpt}` : 'Excerpt: (not available) — call getNodesById if you need more context.',
|
||||
'====================='
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function hydrateReferencedNodes(nodeIds: number[]): Promise<Node[]> {
|
||||
const nodes: Node[] = [];
|
||||
for (const id of nodeIds) {
|
||||
try {
|
||||
const node = await nodeService.getNodeById(id);
|
||||
if (node) {
|
||||
nodes.push(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`delegateToMiniRAH: failed to load node ${id}`, error);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export const delegateToMiniRAHTool = tool({
|
||||
description: 'Delegate task to mini worker',
|
||||
inputSchema: z.object({
|
||||
task: z.string().describe('Clear, actionable description of what the mini ra-h should do'),
|
||||
context: z.array(z.string()).max(16).default([]).describe('Optional context: URLs, node IDs, or key information the worker needs'),
|
||||
expectedOutcome: z.string().optional().describe('Optional: what format or structure you expect in the summary'),
|
||||
}),
|
||||
execute: async ({ task, context = [], expectedOutcome }) => {
|
||||
const requestContext = RequestContext.get();
|
||||
const openTabs = (requestContext.openTabs ?? []) as Node[];
|
||||
const activeTabId = requestContext.activeTabId ?? null;
|
||||
|
||||
const providedEntries = Array.isArray(context) ? context.filter(entry => typeof entry === 'string') as string[] : [];
|
||||
const referencedNodeIds = new Set<number>();
|
||||
const passthroughEntries: string[] = [];
|
||||
|
||||
providedEntries.forEach(entry => {
|
||||
const trimmed = entry.trim();
|
||||
const numericMatch = trimmed.match(/^(?:node_id:)?(\d+)$/i);
|
||||
if (numericMatch) {
|
||||
const id = Number(numericMatch[1]);
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
referencedNodeIds.add(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
passthroughEntries.push(entry);
|
||||
});
|
||||
|
||||
const focusMap = new Map<number, Node>();
|
||||
openTabs.forEach(node => {
|
||||
if (node && typeof node.id === 'number') {
|
||||
focusMap.set(node.id, node);
|
||||
}
|
||||
});
|
||||
|
||||
const additionalIds = Array.from(referencedNodeIds).filter(id => !focusMap.has(id));
|
||||
const referencedNodes = await hydrateReferencedNodes(additionalIds);
|
||||
|
||||
referencedNodes.forEach(node => focusMap.set(node.id, node));
|
||||
|
||||
const focusNodes = Array.from(focusMap.values());
|
||||
const primaryNode = focusNodes.find(node => node.id === activeTabId) || focusNodes[0] || null;
|
||||
const secondaryNodes = focusNodes.filter(node => primaryNode && node.id !== primaryNode.id && openTabs.some(tab => tab.id === node.id));
|
||||
const referencedOnlyNodes = focusNodes.filter(node => !openTabs.some(tab => tab.id === node.id));
|
||||
|
||||
const capsule: DelegationCapsule = {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
primary: primaryNode ? buildSnapshot(primaryNode, 'primary') : null,
|
||||
secondary: secondaryNodes.map(node => buildSnapshot(node, 'secondary')),
|
||||
referenced: referencedOnlyNodes.map(node => buildSnapshot(node, 'referenced')),
|
||||
focusCount: openTabs.length,
|
||||
};
|
||||
|
||||
const sourceBlocks: string[] = [];
|
||||
const snapshots: CapsuleNodeSnapshot[] = [];
|
||||
if (capsule.primary) snapshots.push(capsule.primary);
|
||||
snapshots.push(...capsule.secondary, ...capsule.referenced);
|
||||
snapshots.forEach(snapshot => {
|
||||
sourceBlocks.push(buildSourceBlock(snapshot));
|
||||
});
|
||||
|
||||
const enrichedContext: string[] = [
|
||||
`CAPSULE_JSON::${JSON.stringify(capsule)}`,
|
||||
formatCapsuleForLLM(capsule),
|
||||
...sourceBlocks,
|
||||
...passthroughEntries,
|
||||
].slice(0, 16);
|
||||
|
||||
const delegation = AgentDelegationService.createDelegation({
|
||||
task,
|
||||
context: enrichedContext,
|
||||
expectedOutcome,
|
||||
supabaseToken: null,
|
||||
});
|
||||
|
||||
const execution = await MiniRAHExecutor.execute({
|
||||
sessionId: delegation.sessionId,
|
||||
task,
|
||||
context: enrichedContext,
|
||||
expectedOutcome,
|
||||
traceId: requestContext.traceId,
|
||||
parentChatId: requestContext.parentChatId,
|
||||
workflowKey: requestContext.workflowKey,
|
||||
workflowNodeId: requestContext.workflowNodeId,
|
||||
});
|
||||
|
||||
const summary = execution?.summary || 'Task delegated but no summary returned.';
|
||||
const status = execution?.status || 'completed';
|
||||
const sessionSuffix = delegation.sessionId.split('_').pop();
|
||||
const statusLabel = status === 'completed' ? 'completed the task' : 'flagged an issue';
|
||||
return `Mini ra-h (session ${sessionSuffix}) ${statusLabel}:\n\n${summary}`;
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor';
|
||||
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
|
||||
export const delegateToWiseRAHTool = tool({
|
||||
@@ -25,7 +25,7 @@ export const delegateToWiseRAHTool = tool({
|
||||
supabaseToken: null,
|
||||
});
|
||||
|
||||
const execution = await WiseRAHExecutor.execute({
|
||||
const execution = await WorkflowExecutor.execute({
|
||||
sessionId: delegation.sessionId,
|
||||
task,
|
||||
context,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { delegateToMiniRAHTool } from './delegateToMiniRAH';
|
||||
|
||||
export const delegateNodeQuotesTool = tool({
|
||||
description: 'Extract quotes from single node',
|
||||
inputSchema: z.object({
|
||||
nodeId: z.number().int().positive().describe('The node to read from'),
|
||||
question: z.string().min(3).describe('The question or prompt the quotes should answer'),
|
||||
maxQuotes: z.number().int().min(1).max(6).default(3).describe('Maximum number of quotes to return'),
|
||||
requireSummary: z.boolean().default(false).describe('Whether to include a short synthesis after the quotes'),
|
||||
}),
|
||||
execute: async ({ nodeId, question, maxQuotes, requireSummary }) => {
|
||||
const task = `Extract up to ${maxQuotes} ready-to-use verbatim quotes (include speaker/source if present) from [NODE:${nodeId}] that answer: "${question}". Provide each quote with a one-line takeaway.`;
|
||||
const context = [String(nodeId), `REQUIRE_SYNTHESIS:${requireSummary ? 'yes' : 'no'}`];
|
||||
const expectedOutcome = requireSummary
|
||||
? 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, list the quotes (prefixed with takeaways) first, then add a 2-3 sentence comparison summary. Always finish with "Context sources used" listing every NODE ID referenced.'
|
||||
: 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, list only the quotes (each prefixed with a takeaway) and end the summary with "Context sources used" covering every NODE ID referenced.';
|
||||
|
||||
if (typeof delegateToMiniRAHTool.execute !== 'function') {
|
||||
throw new Error('delegateToMiniRAH tool is unavailable.');
|
||||
}
|
||||
|
||||
return delegateToMiniRAHTool.execute({
|
||||
task,
|
||||
context,
|
||||
expectedOutcome,
|
||||
}, undefined as any);
|
||||
}
|
||||
});
|
||||
|
||||
export const delegateNodeComparisonTool = tool({
|
||||
description: 'Create synthesis from gathered evidence',
|
||||
inputSchema: z.object({
|
||||
title: z.string().min(3).describe('Title for the synthesis node'),
|
||||
comparisonPrompt: z.string().min(5).describe('Short description of what to compare or conclude'),
|
||||
sourceNodeIds: z.array(z.number().int().positive()).min(1).max(8).describe('Source nodes that must be cited'),
|
||||
includeOutline: z.boolean().default(false).describe('If true, worker should draft with numbered sections matching current outline in context'),
|
||||
}),
|
||||
execute: async ({ title, comparisonPrompt, sourceNodeIds, includeOutline }) => {
|
||||
const task = `Using the gathered evidence, draft the final synthesis titled "${title}". Compare or conclude on: ${comparisonPrompt}.`;
|
||||
const outlineHint = includeOutline
|
||||
? 'Follow the outline provided in the context exactly (use the same headings).'
|
||||
: 'Structure the answer with clear sections (Intro, Comparison, Takeaways).';
|
||||
const contextEntries = sourceNodeIds.map(id => String(id));
|
||||
contextEntries.push(outlineHint);
|
||||
|
||||
const expectedOutcome = 'Use the required Task/Actions/Result/Node/Context sources used/Follow-up template. In the Result line, deliver polished prose ready for createNode/updateNode, cite specific quotes inline where relevant, and finish with "Context sources used" listing every NODE ID you relied on.';
|
||||
|
||||
if (typeof delegateToMiniRAHTool.execute !== 'function') {
|
||||
throw new Error('delegateToMiniRAH tool is unavailable.');
|
||||
}
|
||||
|
||||
return delegateToMiniRAHTool.execute({
|
||||
task,
|
||||
context: contextEntries,
|
||||
expectedOutcome,
|
||||
}, undefined as any);
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor';
|
||||
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
import { getAutoContextSummaries } from '@/services/context/autoContext';
|
||||
|
||||
@@ -105,7 +105,7 @@ ${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general wor
|
||||
});
|
||||
|
||||
// Fire-and-forget execution so main ra-h stays responsive while workflow runs
|
||||
void WiseRAHExecutor.execute({
|
||||
void WorkflowExecutor.execute({
|
||||
sessionId: delegation.sessionId,
|
||||
task,
|
||||
context: contextLines,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Get database path (same logic as sqlite-client.ts)
|
||||
function getDatabasePath(): string {
|
||||
return process.env.SQLITE_DB_PATH || path.join(
|
||||
process.env.HOME || '~',
|
||||
'Library/Application Support/RA-H/db/rah.sqlite'
|
||||
);
|
||||
}
|
||||
|
||||
// Security: Only allow SELECT statements
|
||||
function isReadOnlyQuery(sql: string): boolean {
|
||||
const normalized = sql.trim().toLowerCase();
|
||||
|
||||
// Must start with SELECT, WITH (for CTEs), or PRAGMA (for schema inspection)
|
||||
const allowedPrefixes = ['select', 'with', 'pragma'];
|
||||
const startsWithAllowed = allowedPrefixes.some(prefix =>
|
||||
normalized.startsWith(prefix)
|
||||
);
|
||||
|
||||
if (!startsWithAllowed) return false;
|
||||
|
||||
// Block dangerous patterns even in subqueries
|
||||
const dangerousPatterns = [
|
||||
/\binsert\b/i,
|
||||
/\bupdate\b/i,
|
||||
/\bdelete\b/i,
|
||||
/\bdrop\b/i,
|
||||
/\bcreate\b/i,
|
||||
/\balter\b/i,
|
||||
/\battach\b/i,
|
||||
/\bdetach\b/i,
|
||||
/\breindex\b/i,
|
||||
/\bvacuum\b/i,
|
||||
/\banalyze\b/i,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(sql));
|
||||
}
|
||||
|
||||
export const sqliteQueryTool = tool({
|
||||
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, edges, dimensions, chunks. Use PRAGMA table_info(tablename) for schema.',
|
||||
|
||||
inputSchema: z.object({
|
||||
sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'),
|
||||
format: z.enum(['table', 'json', 'csv']).default('table').describe('Output format: table (default), json, or csv'),
|
||||
}),
|
||||
|
||||
execute: async ({ sql, format = 'table' }) => {
|
||||
console.log('🔍 SQLite Query tool called:', sql.substring(0, 100));
|
||||
|
||||
// Security check
|
||||
if (!isReadOnlyQuery(sql)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Only SELECT, WITH, and PRAGMA statements are allowed. Write operations must use dedicated tools (createNode, updateNode, etc.).',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const dbPath = getDatabasePath();
|
||||
|
||||
// Build sqlite3 command with appropriate output mode
|
||||
let modeFlag = '';
|
||||
switch (format) {
|
||||
case 'json':
|
||||
modeFlag = '-json';
|
||||
break;
|
||||
case 'csv':
|
||||
modeFlag = '-csv -header';
|
||||
break;
|
||||
case 'table':
|
||||
default:
|
||||
modeFlag = '-header -column';
|
||||
break;
|
||||
}
|
||||
|
||||
// Escape the SQL for shell (replace single quotes)
|
||||
const escapedSql = sql.replace(/'/g, "'\"'\"'");
|
||||
const command = `sqlite3 ${modeFlag} "${dbPath}" '${escapedSql}'`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 5000, // 5 second timeout
|
||||
maxBuffer: 1024 * 1024, // 1MB max output
|
||||
});
|
||||
|
||||
if (stderr && !stdout) {
|
||||
return {
|
||||
success: false,
|
||||
error: stderr,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse JSON output if requested
|
||||
let data: any = stdout.trim();
|
||||
if (format === 'json' && data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// Keep as string if parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Count rows for message
|
||||
let rowCount = 0;
|
||||
if (format === 'json' && Array.isArray(data)) {
|
||||
rowCount = data.length;
|
||||
} else if (typeof data === 'string') {
|
||||
// Count non-empty lines minus header
|
||||
const lines = data.split('\n').filter(line => line.trim());
|
||||
rowCount = Math.max(0, lines.length - 1); // Subtract header row
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: `Query returned ${rowCount} row${rowCount !== 1 ? 's' : ''}.`,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle timeout
|
||||
if (error.killed) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Query timed out after 5 seconds. Try a simpler query or add LIMIT.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Query execution failed',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user