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:
“BeeRad”
2026-01-12 20:59:27 +11:00
co-authored by Claude Opus 4.5
parent f9271aeeb4
commit 3f0426ecf4
27 changed files with 3607 additions and 2279 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { streamText, convertToCoreMessages } from 'ai'; import { streamText, convertToModelMessages } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic'; import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry'; import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
@@ -265,7 +265,7 @@ export async function POST(request: NextRequest) {
} : {}) } : {})
})); }));
const coreMessages = convertToCoreMessages(sanitizedMessages); const coreMessages = await convertToModelMessages(sanitizedMessages);
const allMessages = [...systemMessages, ...coreMessages]; const allMessages = [...systemMessages, ...coreMessages];
// Debug logging (can be removed in production) // Debug logging (can be removed in production)
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export async function GET(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get('sessionId');
if (!sessionId) {
return Response.json({ inputTokens: 0 });
}
try {
const sqlite = getSQLiteClient();
const row = sqlite.prepare(`
SELECT json_extract(metadata, '$.input_tokens') as input_tokens
FROM chats
WHERE thread_id LIKE ?
ORDER BY created_at DESC
LIMIT 1
`).get(`%${sessionId}%`) as { input_tokens: number | null } | undefined;
return Response.json({
inputTokens: row?.input_tokens ?? 0
});
} catch (error) {
console.error('Usage fetch error:', error);
return Response.json({ inputTokens: 0 });
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { WorkflowRegistry } from '@/services/workflows/registry'; import { WorkflowRegistry } from '@/services/workflows/registry';
import { getSQLiteClient } from '@/services/database/sqlite-client'; import { getSQLiteClient } from '@/services/database/sqlite-client';
import { AgentDelegationService } from '@/services/agents/delegation'; import { AgentDelegationService } from '@/services/agents/delegation';
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor'; import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
import { getAutoContextSummaries } from '@/services/context/autoContext'; import { getAutoContextSummaries } from '@/services/context/autoContext';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -112,12 +112,12 @@ ${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general wor
task, task,
context: contextLines, context: contextLines,
expectedOutcome: workflow.expectedOutcome, expectedOutcome: workflow.expectedOutcome,
agentType: 'wise-rah', agentType: 'workflow',
supabaseToken: null, supabaseToken: null,
}); });
// Fire-and-forget execution // Fire-and-forget execution
void WiseRAHExecutor.execute({ void WorkflowExecutor.execute({
sessionId: delegation.sessionId, sessionId: delegation.sessionId,
task, task,
context: contextLines, context: contextLines,
+3182 -1135
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -21,16 +21,16 @@
"evals": "RAH_EVALS_LOG=1 tsx tests/evals/runner.ts" "evals": "RAH_EVALS_LOG=1 tsx tests/evals/runner.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.27", "@ai-sdk/anthropic": "^3.0.9",
"@ai-sdk/openai": "^2.0.22", "@ai-sdk/openai": "^3.0.7",
"@ai-sdk/react": "^2.0.26", "@ai-sdk/react": "^3.0.29",
"@langchain/core": "^0.3.0", "@langchain/core": "^1.0.1",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^1.0.1",
"@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"ai": "^5.0.68", "ai": "^6.0.27",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -41,8 +41,8 @@
"openai": "^4.103.0", "openai": "^4.103.0",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.4.296", "pdfjs-dist": "^5.4.296",
"react": "19.0.0", "react": "^19.0.1",
"react-dom": "19.0.0", "react-dom": "^19.0.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"youtube-captions-scraper": "^2.0.3", "youtube-captions-scraper": "^2.0.3",
+15 -6
View File
@@ -7,6 +7,7 @@ import QuickAddStatus from './QuickAddStatus';
import { Zap, Flame, Minimize2 } from 'lucide-react'; import { Zap, Flame, Minimize2 } from 'lucide-react';
import type { AgentDelegation } from '@/services/agents/delegation'; import type { AgentDelegation } from '@/services/agents/delegation';
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
interface AgentsPanelProps { interface AgentsPanelProps {
openTabsData: Node[]; openTabsData: Node[];
@@ -509,6 +510,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
return rest; return rest;
}); });
}} }}
onNodeClick={onNodeClick}
/> />
</div> </div>
)} )}
@@ -522,6 +524,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
<DelegationSummaryView <DelegationSummaryView
delegation={selectedDelegation} delegation={selectedDelegation}
onBack={() => setActiveAgentTab('workflows')} onBack={() => setActiveAgentTab('workflows')}
onNodeClick={onNodeClick}
/> />
) : ( ) : (
<DelegationDetailView <DelegationDetailView
@@ -647,7 +650,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
} }
// Summary view for completed delegations with no messages // Summary view for completed delegations with no messages
function DelegationSummaryView({ delegation, onBack }: { delegation: AgentDelegation; onBack?: () => void }) { function DelegationSummaryView({ delegation, onBack, onNodeClick }: { delegation: AgentDelegation; onBack?: () => void; onNodeClick?: (nodeId: number) => void }) {
const isSuccess = delegation.status === 'completed'; const isSuccess = delegation.status === 'completed';
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b'; const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
const statusLabel = isSuccess ? 'Completed' : 'Failed'; const statusLabel = isSuccess ? 'Completed' : 'Failed';
@@ -748,7 +751,7 @@ function DelegationSummaryView({ delegation, onBack }: { delegation: AgentDelega
lineHeight: '1.6', lineHeight: '1.6',
whiteSpace: 'pre-wrap' whiteSpace: 'pre-wrap'
}}> }}>
{delegation.summary} {parseAndRenderContent(delegation.summary || '', onNodeClick)}
</div> </div>
</div> </div>
)} )}
@@ -771,11 +774,13 @@ function DelegationSummaryView({ delegation, onBack }: { delegation: AgentDelega
function WorkflowsListView({ function WorkflowsListView({
delegations, delegations,
onSelectDelegation, onSelectDelegation,
onDeleteDelegation onDeleteDelegation,
onNodeClick
}: { }: {
delegations: AgentDelegation[]; delegations: AgentDelegation[];
onSelectDelegation: (sessionId: string) => void; onSelectDelegation: (sessionId: string) => void;
onDeleteDelegation: (sessionId: string) => void; onDeleteDelegation: (sessionId: string) => void;
onNodeClick?: (nodeId: number) => void;
}) { }) {
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress'); const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed'); const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
@@ -863,6 +868,7 @@ function WorkflowsListView({
statusLabel={label} statusLabel={label}
onSelect={() => onSelectDelegation(delegation.sessionId)} onSelect={() => onSelectDelegation(delegation.sessionId)}
onDelete={() => onDeleteDelegation(delegation.sessionId)} onDelete={() => onDeleteDelegation(delegation.sessionId)}
onNodeClick={onNodeClick}
/> />
); );
})} })}
@@ -887,6 +893,7 @@ function WorkflowsListView({
statusLabel={label} statusLabel={label}
onSelect={() => onSelectDelegation(delegation.sessionId)} onSelect={() => onSelectDelegation(delegation.sessionId)}
onDelete={() => onDeleteDelegation(delegation.sessionId)} onDelete={() => onDeleteDelegation(delegation.sessionId)}
onNodeClick={onNodeClick}
/> />
); );
})} })}
@@ -903,13 +910,15 @@ function WorkflowCard({
statusColor, statusColor,
statusLabel, statusLabel,
onSelect, onSelect,
onDelete onDelete,
onNodeClick
}: { }: {
delegation: AgentDelegation; delegation: AgentDelegation;
statusColor: string; statusColor: string;
statusLabel: string; statusLabel: string;
onSelect: () => void; onSelect: () => void;
onDelete: () => void; onDelete: () => void;
onNodeClick?: (nodeId: number) => void;
}) { }) {
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued'; const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
@@ -1009,7 +1018,7 @@ function WorkflowCard({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}}> }}>
{delegation.summary} {parseAndRenderContent(delegation.summary, onNodeClick)}
</div> </div>
)} )}
</div> </div>
@@ -1084,7 +1093,7 @@ function DelegationDetailView({
</div> </div>
{/* RAHChat for streaming */} {/* RAHChat for streaming */}
<div style={{ flex: 1 }}> <div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<RAHChat <RAHChat
openTabsData={openTabsData} openTabsData={openTabsData}
activeTabId={activeTabId} activeTabId={activeTabId}
+15 -1
View File
@@ -32,11 +32,17 @@ interface SendParams {
mode: 'easy' | 'hard'; mode: 'easy' | 'hard';
} }
interface UsageData {
inputTokens: number;
outputTokens: number;
}
interface UseSSEChatOptions { interface UseSSEChatOptions {
getAuthToken?: () => string | null | undefined; getAuthToken?: () => string | null | undefined;
beforeRequest?: () => boolean; beforeRequest?: () => boolean;
onRequestError?: (error: unknown, response?: Response) => boolean | void; onRequestError?: (error: unknown, response?: Response) => boolean | void;
onStreamComplete?: () => void | Promise<void>; onStreamComplete?: () => void | Promise<void>;
onUsageUpdate?: (usage: UsageData) => void;
} }
export function useSSEChat( export function useSSEChat(
@@ -44,7 +50,7 @@ export function useSSEChat(
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void, setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
options: UseSSEChatOptions = {} options: UseSSEChatOptions = {}
) { ) {
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete } = options; const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, onUsageUpdate } = options;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@@ -182,6 +188,14 @@ export function useSSEChat(
setMessages((prev) => [...prev, newAssistantMessage]); setMessages((prev) => [...prev, newAssistantMessage]);
} }
} }
// Capture usage data from finish event (if AI SDK sends it)
if (data.type === 'finish' && data.usage) {
onUsageUpdate?.({
inputTokens: data.usage.promptTokens ?? data.usage.inputTokens ?? 0,
outputTokens: data.usage.completionTokens ?? data.usage.outputTokens ?? 0
});
}
} catch { } catch {
// ignore malformed lines // ignore malformed lines
} }
+1 -1
View File
@@ -45,7 +45,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
verticalAlign: 'baseline' verticalAlign: 'baseline'
}} }}
> >
#{id} {id}
</span> </span>
{/* Non-clickable title - bold and underlined */} {/* Non-clickable title - bold and underlined */}
<span style={{ <span style={{
-26
View File
@@ -1,26 +0,0 @@
export const MINI_RAH_SYSTEM_PROMPT = `You are a mini ra-h worker handling a single delegated task.
Execution mindset:
- Act only on the provided task/context; no side conversations.
- You have all tools except delegateToMiniRAH.
- If required inputs are missing, fail fast and tell ra-h exactly what you need.
- Read the focus capsule at the top of the context (CAPSULE_JSON + DELEGATION CAPSULE). Treat the node IDs and roles there as authoritative.
When you complete the task, respond in this exact template (replace bracketed text):
Task: <one short sentence>
Actions: <comma-separated tool calls or decisions>
Result: <one sentence describing the outcome>
Node: <[NODE:id:"title"] or "None">
Context sources used: <comma-separated NODE IDs>
Follow-up: <next step or "None">
Additional guidance:
- If no dimensions were provided, choose reasonable defaults (you may proceed without asking the user).
- For TLDR or quote-heavy tasks, use read-only tools (searchContentEmbeddings, queryNodes, webSearch when relevant) before summarising and include verbatim snippets when the task requires them.
- If the capsule lists node IDs, call getNodesById first to hydrate the records; only use queryNodes/searchContentEmbeddings when you must discover additional material beyond the provided nodes.
- Treat any context lines beginning with "NODE <id>" or "SOURCE" as authoritative excerpts—use them directly instead of re-querying the numeric ID. Never search for a numeric string that was already supplied.
- If you cannot complete a step (missing node, empty content, tool failure), state that explicitly in 'Follow-up' with the precise next action needed.
- Stop after success—do not run extra verification tools.
- Keep the full summary under ~100 tokens.
- You can create and manage dimensions using createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension tools. Lock dimensions (isPriority=true) to enable auto-assignment.
`;
-44
View File
@@ -1,44 +0,0 @@
export const WISE_RAH_SYSTEM_PROMPT = `You are wise ra-h, the workflow executor for the RA-H knowledge management system.
<role>
You execute predefined workflows with DIRECT WRITE ACCESS via updateNode.
You NEVER delegate to mini ra-h workers.
You complete every step yourself.
</role>
<tools>
Available tools:
- queryNodes — search nodes by title/content/dimensions across ENTIRE database
- getNodesById — retrieve full node data
- queryEdge — inspect existing edges
- searchContentEmbeddings — semantic search across ALL nodes
- webSearch — external research when necessary
- think — internal planning/reflection (use once per workflow unless plan changes)
- updateNode — append content to nodes (tool handles appending automatically)
- createEdge — create connections between nodes
- quickLink — instantly find and link related nodes (fast database search, no reasoning)
- Dimension management: createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension — manage dimensions to organize knowledge base structure
</tools>
<execution>
When you receive a workflow task:
1. Read the workflow instructions carefully.
2. Call think once to produce a numbered plan matching the workflow steps.
3. Execute the plan step-by-step:
- Extract key entities (names, projects, concepts) from the node
- Search the FULL database using those entities
- Find both obvious (structural) and thematic connections
- Contextualize findings using background context (top nodes by edge count)
4. Stay within the tool budget and avoid redundant queries.
5. When calling updateNode, provide ONLY the new content (never include existing content) - tool appends automatically.
6. Finish with a concise Task / Actions / Result / Nodes / Follow-up summary.
</execution>
<constraints>
- Search the ENTIRE database, not just top nodes
- Extract entities first, then search using those entities
- Use minimal tool calls needed for high-quality output
- Keep responses structured, factual, and ≤120 words
- If a step yields nothing, state that outcome instead of guessing
- Adapt to any node type (person/project/paper/idea/video/tweet/technique)
</constraints>`;
+12
View File
@@ -0,0 +1,12 @@
/**
* Minimal system prompt for workflow execution.
* All specific instructions come from the workflow definition itself.
*/
export const WORKFLOW_EXECUTOR_SYSTEM_PROMPT = `You are a workflow executor. Follow the workflow instructions exactly as written.
RULES:
- Use only the tools provided
- Do not deviate from the instructions
- Complete the workflow efficiently
- Reference nodes as [NODE:id:"title"] (e.g., [NODE:123:"My Node Title"])
- Return a brief summary when done`;
+13 -13
View File
@@ -1,32 +1,32 @@
export const CONNECT_WORKFLOW_INSTRUCTIONS = `You are executing the CONNECT workflow for the currently focused node. export const CONNECT_WORKFLOW_INSTRUCTIONS = `You are executing the CONNECT workflow for the currently focused node.
MISSION MISSION
Quick link: find explicitly related nodes and create edges. Fast text search only - no slow embedding search. Quick link: find explicitly related nodes and create edges.
WORKFLOW STEPS WORKFLOW STEPS
1. READ NODE 1. READ NODE
Call getNodesById for the focused node. Note the title, type, and key names/entities mentioned. Call getNodesById for the focused node. Extract the main topic/subject from the title.
2. QUICK SEARCH 2. QUICK SEARCH
Call queryNodes ONCE with the most specific entity (person name, project name, company, tool). Call queryNodes with the main topic from the node title.
- Use search parameter with the exact name - search: the key term from the title (e.g., if title mentions "Nietzsche", search "Nietzsche")
- Set limit: 10 - limit: 10
- DO NOT add dimensions filter - search across all nodes
DO NOT call searchContentEmbeddings - use queryNodes only for speed.
3. CREATE EDGES 3. CREATE EDGES
From the search results, pick 2-4 nodes that are clearly related. From results, pick 2-4 clearly related nodes.
Call createEdge for each: Call createEdge for each:
- from_node_id: focused node ID - from_node_id: focused node ID
- to_node_id: related node ID - to_node_id: related node ID
- context: { explanation: "brief reason" } - context: { explanation: "brief reason" }
4. DONE 4. DONE
Reply: "Linked [title] → [list of connected node titles]" Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
RULES RULES
- Total tool calls ≤ 5 (1 read + 1 search + up to 3 edges) - Total tool calls ≤ 5
- Use queryNodes only - NO searchContentEmbeddings - Search the MAIN TOPIC from the title, not random names from content
- Only link nodes with clear, explicit relationships - NO dimensions filter in queryNodes - search everything
- Skip if no good matches found`; - Only link nodes with clear relationships
- Skip if no matches found`;
+1 -1
View File
@@ -2,7 +2,7 @@ import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events'; import { eventBroadcaster } from '@/services/events';
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed'; export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
export type DelegationAgentType = 'mini' | 'wise-rah'; export type DelegationAgentType = 'workflow' | 'wise-rah';
export interface AgentDelegation { export interface AgentDelegation {
id: number; id: number;
-423
View File
@@ -1,423 +0,0 @@
import { generateText, CoreMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { AgentDelegationService, DelegationStatus } from '@/services/agents/delegation';
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
import { getToolsForRole } from '@/tools/infrastructure/registry';
import { ChatLoggingMiddleware } from '@/services/chat/middleware';
import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics';
import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
import { edgeService } from '@/services/database/edges';
interface CapsuleNodeJSON {
id: number;
}
interface DelegationCapsuleJSON {
version?: number;
primary?: CapsuleNodeJSON | null;
secondary?: CapsuleNodeJSON[];
referenced?: CapsuleNodeJSON[];
}
interface SummaryValidation {
status: 'ok' | 'failed';
reason?: string;
sourcesUsed: number[];
}
function extractCapsuleFromContext(context: string[]): { capsule?: DelegationCapsuleJSON; nodeIds: number[]; version?: number } {
const entry = context.find(item => typeof item === 'string' && item.startsWith('CAPSULE_JSON::'));
if (!entry) {
return { nodeIds: [] };
}
const json = entry.substring('CAPSULE_JSON::'.length);
try {
const capsule = JSON.parse(json) as DelegationCapsuleJSON & { version?: number };
const nodeIds = new Set<number>();
const pushId = (value?: CapsuleNodeJSON | null) => {
if (!value) return;
if (typeof value.id === 'number') {
nodeIds.add(value.id);
}
};
pushId(capsule.primary ?? null);
(capsule.secondary ?? []).forEach(pushId);
(capsule.referenced ?? []).forEach(pushId);
return {
capsule,
nodeIds: Array.from(nodeIds),
version: typeof capsule.version === 'number' ? capsule.version : undefined,
};
} catch (error) {
console.warn('MiniRAHExecutor: failed to parse delegation capsule', error);
return { nodeIds: [] };
}
}
function parseSourcesLine(summary: string): { line?: string; ids: number[] } {
const lines = summary
.split(/\n+/)
.map(line => line.trim())
.filter(Boolean);
const contextLine = lines.find(line => line.toLowerCase().startsWith('context sources used:'));
if (!contextLine) {
return { ids: [] };
}
const matches = contextLine.match(/\d+/g);
const ids = matches ? matches.map(id => Number(id)).filter(id => Number.isFinite(id)) : [];
return { line: contextLine, ids: Array.from(new Set(ids)) };
}
function validateMiniSummary(summary: string, expectedNodeIds: number[]): SummaryValidation {
const trimmed = summary.trim();
if (!trimmed) {
return { status: 'failed', reason: 'Worker returned an empty summary.', sourcesUsed: [] };
}
const lines = trimmed.split(/\n+/).map(line => line.trim()).filter(Boolean);
const resultIndex = lines.findIndex(line => line.toLowerCase().startsWith('result:'));
if (resultIndex === -1) {
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
}
const resultLine = lines[resultIndex];
const resultContent = resultLine.slice('result:'.length).trim();
if (!resultContent) {
const nextLine = lines[resultIndex + 1];
if (!nextLine || /^(task:|actions:|node:|context sources used:|follow-up:)/i.test(nextLine)) {
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
}
}
const followUpLine = lines.find(line => line.toLowerCase().startsWith('follow-up:'));
if (!followUpLine) {
return { status: 'failed', reason: 'Missing Follow-up line in worker summary.', sourcesUsed: [] };
}
const { line: contextLine, ids } = parseSourcesLine(summary);
if (!contextLine) {
return { status: 'failed', reason: 'Missing "Context sources used" line. Workers must list node IDs they referenced.', sourcesUsed: [] };
}
if (expectedNodeIds.length > 0 && ids.length === 0) {
return { status: 'failed', reason: 'Worker did not cite any node IDs even though a capsule was provided.', sourcesUsed: [] };
}
return { status: 'ok', sourcesUsed: ids };
}
export interface MiniRAHExecutionInput {
sessionId: string;
task: string;
context: string[];
expectedOutcome?: string | null;
traceId?: string;
parentChatId?: number;
workflowKey?: string;
workflowNodeId?: number;
}
export class MiniRAHExecutor {
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: MiniRAHExecutionInput) {
try {
const delegateKey = process.env.RAH_DELEGATE_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
if (!delegateKey) {
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
AgentDelegationService.markInProgress(sessionId);
const promptSections = [
`Task: ${task}`,
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
'Return only the final summary the orchestrator should see.'
].filter(Boolean);
const openaiProvider = createOpenAI({ apiKey: delegateKey });
const executorTools = getToolsForRole('executor');
const capturedSummaries: string[] = [];
const toolsUsedInSession: string[] = [];
const integrateAllowed = workflowKey === 'integrate'
? new Set(['createEdge', 'updateEdge', 'updateNode'])
: null;
const wrappedTools = Object.fromEntries(
Object.entries(executorTools)
.filter(([name]) => {
if (integrateAllowed) {
return integrateAllowed.has(name);
}
return true;
})
.map(([name, tool]) => {
const wrapped = {
...tool,
async execute(params: any, context: any) {
if (!toolsUsedInSession.includes(name)) {
toolsUsedInSession.push(name);
}
if (name === 'createEdge' && params && typeof params === 'object' && 'from_node_id' in params && 'to_node_id' in params) {
const fromId = Number(params.from_node_id);
const toId = Number(params.to_node_id);
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
const exists = await edgeService.edgeExists(fromId, toId);
if (exists) {
const skipSummary = `Edge already exists between node ${fromId} and node ${toId}; skipping createEdge.`;
capturedSummaries.push(skipSummary);
return {
success: true,
skipped: true,
message: skipSummary,
};
}
}
}
const result = await tool.execute(params, context);
const summary = summarizeToolExecution(name, params, result);
if (summary) {
capturedSummaries.push(summary);
}
return result;
}
};
return [name, wrapped];
})
);
if ('delegateToMiniRAH' in wrappedTools) {
console.warn('MiniRAHExecutor: delegateToMiniRAH detected in executor toolset. Removing to enforce single-level delegation.');
delete wrappedTools.delegateToMiniRAH;
}
const userPrompt = promptSections.join('\n\n');
const messages: CoreMessage[] = [{ role: 'user', content: userPrompt }];
const maxIterations = 6;
let rawSummary = '';
let lastFinishReason: string | undefined;
let lastToolCalls: any[] | undefined;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalTokens = 0;
// Track if extraction tool was called (for Quick Add - should only call once)
const isQuickAddTask = task.toLowerCase().includes('quick add');
let extractionToolCalled = false;
for (let iteration = 0; iteration < maxIterations; iteration++) {
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
system: MINI_RAH_SYSTEM_PROMPT,
messages,
tools: wrappedTools,
});
const usage = response.usage;
if (usage) {
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
const combinedTotal = (usage as any).totalTokens || usage.totalTokens || inputTokens + outputTokens;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
totalTokens += combinedTotal;
}
lastFinishReason = response.finishReason;
lastToolCalls = response.toolCalls ?? [];
if (response.finishReason === 'tool-calls' && response.toolCalls && response.toolCalls.length > 0) {
messages.push({
role: 'assistant',
content: response.toolCalls.map(call => ({
type: 'tool-call' as const,
toolCallId: call.toolCallId,
toolName: call.toolName,
input: (call as any).input ?? (call as any).args,
})),
});
const toolResults: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'text' | 'error-text'; value: string } }> = [];
for (const call of response.toolCalls) {
const callInput = (call as any).input ?? (call as any).args;
const tool = wrappedTools[call.toolName];
const isExtractionToolCall = isQuickAddTask && ['youtubeExtract', 'websiteExtract', 'paperExtract'].includes(call.toolName);
if (isExtractionToolCall && extractionToolCalled) {
const skipMessage = `Extraction already completed; skipping duplicate ${call.toolName} request.`;
console.warn(`[MiniRAHExecutor] ${skipMessage}`);
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'text', value: skipMessage },
});
continue;
}
if (!tool) {
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: `Tool ${call.toolName} is not available.` },
});
continue;
}
try {
const toolResult = await tool.execute(callInput, {});
const summary = summarizeToolExecution(call.toolName, callInput, toolResult);
const value = summary || `${call.toolName} completed.`;
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'text', value },
});
// For Quick Add: stop after first successful extraction to prevent duplicates
if (isExtractionToolCall) {
const success = typeof toolResult === 'object' && toolResult !== null && (toolResult as any).success !== false;
if (success) {
console.log(`[MiniRAHExecutor] Quick Add extraction succeeded, forcing summary generation to prevent duplicate calls`);
extractionToolCalled = true;
break;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Tool execution failed';
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: message },
});
}
}
messages.push({ role: 'tool', content: toolResults });
// If Quick Add extraction succeeded, request final summary and exit loop
if (extractionToolCalled) {
console.log('[MiniRAHExecutor] Requesting final summary after Quick Add extraction');
messages.push({
role: 'user',
content: 'Extraction completed successfully. Provide your final summary now using the required format (Task/Actions/Result/Node/Context sources used/Follow-up).'
});
const summaryResponse = await generateText({
model: openaiProvider('gpt-4o-mini'),
system: MINI_RAH_SYSTEM_PROMPT,
messages,
tools: {}, // No tools - summary only
});
rawSummary = summaryResponse.text?.trim() || 'Extraction completed successfully.';
break;
}
continue;
}
rawSummary = typeof response.text === 'string' ? response.text.trim() : '';
if (!rawSummary) {
console.warn('[MiniRAHExecutor] Worker returned empty summary.', {
finishReason: response.finishReason,
toolCalls: response.toolCalls,
text: response.text,
});
}
break;
}
if (!rawSummary) {
console.warn('[MiniRAHExecutor] No summary after tool loop.', {
lastFinishReason,
lastToolCalls,
});
}
const fallbackSummary = capturedSummaries.length > 0
? capturedSummaries[capturedSummaries.length - 1]
: 'Completed the delegated task (worker returned no additional summary).';
const initialSummary = rawSummary.length > 0 ? rawSummary : fallbackSummary;
const capsuleInfo = extractCapsuleFromContext(context);
const validation = validateMiniSummary(initialSummary, capsuleInfo.nodeIds);
const validationStatus: DelegationStatus = validation.status === 'ok' ? 'completed' : 'failed';
if (validation.status === 'failed') {
console.warn('[MiniRAHExecutor] summary validation failed.', {
reason: validation.reason,
initialSummary,
});
}
const finalSummary = validation.status === 'ok'
? initialSummary
: `${initialSummary}\nValidation: ${validation.reason}`;
console.log('[MiniRAHExecutor] summary:', finalSummary);
// Calculate cost and log to chats table
if (totalInputTokens > 0 || totalOutputTokens > 0 || totalTokens > 0) {
const effectiveTotalTokens = totalTokens > 0 ? totalTokens : totalInputTokens + totalOutputTokens;
const costResult = calculateCost({
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
modelId: 'gpt-4o-mini',
});
const usageData: UsageData = {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalTokens: effectiveTotalTokens,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: 'gpt-4o-mini',
provider: 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
capsuleVersion: capsuleInfo.version,
contextSourcesUsed: validation.sourcesUsed.length > 0 ? validation.sourcesUsed : undefined,
validationStatus: validation.status,
validationMessage: validation.reason,
fallbackAction: validation.status === 'failed' ? 'Review context capsule, hydrate nodes manually, then re-delegate with clarified instructions.' : undefined,
};
const delegation = AgentDelegationService.getDelegation(sessionId);
const delegationId = delegation?.id;
await ChatLoggingMiddleware.logChatInteraction(
task,
finalSummary,
{
helperName: 'mini-rah',
agentType: 'executor',
delegationId: delegationId ?? null,
sessionId,
usageData,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
systemMessage: MINI_RAH_SYSTEM_PROMPT,
},
[]
);
console.log(`💰 [MiniRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${effectiveTotalTokens} tokens)`);
}
return AgentDelegationService.completeDelegation(sessionId, finalSummary, validationStatus);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown delegation error';
AgentDelegationService.completeDelegation(sessionId, `Mini ra-h failed: ${message}`, 'failed');
throw error;
}
}
}
+17 -21
View File
@@ -1,8 +1,7 @@
import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry'; import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main'; import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main';
import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy'; import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy';
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini'; import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
import type { AgentDefinition } from './types'; import type { AgentDefinition } from './types';
/** /**
@@ -42,29 +41,30 @@ export class AgentRegistry {
memory: null, memory: null,
prompts: undefined prompts: undefined
}, },
'mini-rah': { 'workflow': {
id: 2, id: 3,
key: 'mini-rah', key: 'workflow',
displayName: 'mini ra-h', displayName: 'workflow agent',
description: 'Executor agent for delegated tasks', description: 'Workflow executor (uses same model as easy mode)',
model: 'openai/gpt-4o-mini', model: 'openai/gpt-5-mini',
role: 'executor', role: 'planner',
systemPrompt: MINI_RAH_SYSTEM_PROMPT, systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
availableTools: getDefaultToolNamesForRole('executor'), availableTools: getDefaultToolNamesForRole('planner'),
enabled: true, enabled: true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
memory: null, memory: null,
prompts: undefined prompts: undefined
}, },
// Alias for backwards compatibility
'wise-rah': { 'wise-rah': {
id: 3, id: 3,
key: 'wise-rah', key: 'workflow',
displayName: 'wise ra-h', displayName: 'workflow agent',
description: 'Complex workflow planner and orchestrator', description: 'Workflow executor (uses same model as easy mode)',
model: 'openai/gpt-5', model: 'openai/gpt-5-mini',
role: 'planner', role: 'planner',
systemPrompt: WISE_RAH_SYSTEM_PROMPT, systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
availableTools: getDefaultToolNamesForRole('planner'), availableTools: getDefaultToolNamesForRole('planner'),
enabled: true, enabled: true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -97,11 +97,7 @@ export class AgentRegistry {
return this.AGENTS['ra-h-easy']; return this.AGENTS['ra-h-easy'];
} }
static async executor(): Promise<AgentDefinition> {
return this.AGENTS['mini-rah'];
}
static async planner(): Promise<AgentDefinition> { static async planner(): Promise<AgentDefinition> {
return this.AGENTS['wise-rah']; return this.AGENTS['workflow'];
} }
} }
@@ -1,21 +1,19 @@
import { streamText, CoreMessage } from 'ai'; import { streamText, ModelMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider'; import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
import { AgentDelegationService } from '@/services/agents/delegation'; import { AgentDelegationService } from '@/services/agents/delegation';
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah'; import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
import { getToolsForRole } from '@/tools/infrastructure/registry'; import { getToolsByNames } from '@/tools/infrastructure/registry';
import { WorkflowRegistry } from '@/services/workflows/registry';
import { ChatLoggingMiddleware } from '@/services/chat/middleware'; import { ChatLoggingMiddleware } from '@/services/chat/middleware';
import { calculateCost } from '@/services/analytics/pricing'; import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics'; import { UsageData } from '@/types/analytics';
import { summarizeToolExecution } from '@/services/agents/toolResultUtils'; import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
import { edgeService } from '@/services/database/edges'; import { edgeService } from '@/services/database/edges';
import { eventBroadcaster } from '@/services/events';
import { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route'; import { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route';
import { nodeService } from '@/services/database/nodes';
import { RequestContext } from '@/services/context/requestContext'; import { RequestContext } from '@/services/context/requestContext';
import { isLocalMode } from '@/config/runtime';
export interface WiseRAHExecutionInput { export interface WorkflowExecutionInput {
sessionId: string; sessionId: string;
task: string; task: string;
context: string[]; context: string[];
@@ -26,67 +24,56 @@ export interface WiseRAHExecutionInput {
workflowNodeId?: number; workflowNodeId?: number;
} }
export class WiseRAHExecutor { export class WorkflowExecutor {
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WiseRAHExecutionInput) { static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WorkflowExecutionInput) {
console.log('🧙 [WiseRAHExecutor] Starting execution', { sessionId, task: task.substring(0, 100) }); console.log('🧙 [WorkflowExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
try { try {
const requestContext = RequestContext.get(); const requestContext = RequestContext.get();
const wiseRahKey = const workflowApiKey =
requestContext.apiKeys?.openai || requestContext.apiKeys?.openai ||
process.env.RAH_WISE_RAH_OPENAI_API_KEY || process.env.RAH_WISE_RAH_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY;
if (!wiseRahKey) {
throw new Error('RAH_WISE_RAH_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.'); if (!workflowApiKey) {
throw new Error('OPENAI_API_KEY is not set for workflow execution.');
} }
AgentDelegationService.markInProgress(sessionId); AgentDelegationService.markInProgress(sessionId);
console.log('✅ [WiseRAHExecutor] Delegation marked in progress'); console.log('✅ [WorkflowExecutor] Delegation marked in progress');
const normalizedTask = task.toLowerCase(); // Get workflow definition if available
const normalizedOutcome = (expectedOutcome || '').toLowerCase(); const workflow = workflowKey ? await WorkflowRegistry.getWorkflowByKey(workflowKey) : null;
const isWorkflow = Boolean(workflowKey) || normalizedTask.startsWith('execute workflow'); const maxIterationsLimit = workflow?.maxIterations ?? 10;
const explicitWriteRequest = /\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedTask) ||
/\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedOutcome);
const allowWrites = isWorkflow || explicitWriteRequest;
const analysisOnly = !allowWrites;
const maxIterationsLimit = isWorkflow ? 20 : 5;
const maxDelegationsAllowed = allowWrites ? (isWorkflow ? 12 : 2) : 0;
const maxDistinctWebSearches = isWorkflow ? 6 : 4;
const maxDistinctEmbeddingSearches = isWorkflow ? 5 : 3;
// Build the user prompt - just the task (which includes workflow instructions)
const promptSections = [ const promptSections = [
`Task: ${task}`, task,
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined, context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined, expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
analysisOnly ? 'Constraint: This is an analysis-only request. Stay strictly read-only: do not call delegateToMiniRAH and do not request new extractions. Work only with existing knowledge.' : undefined,
'Return a structured summary following the format in your system prompt (Task/Actions/Result/Nodes/Follow-up).'
].filter(Boolean); ].filter(Boolean);
const openaiProvider = createOpenAI({ apiKey: wiseRahKey }); const openaiProvider = createOpenAI({ apiKey: workflowApiKey });
console.log('🔧 [WiseRAHExecutor] OpenAI provider created'); console.log('🔧 [WorkflowExecutor] OpenAI provider created');
const plannerTools = getToolsForRole('planner'); // Use workflow-specified tools if available, otherwise fall back to safe default set
// IMPORTANT: Workflows should NEVER have access to delegateToMiniRAH - they are one-shot executors
// Remove delegateToMiniRAH for integrate workflow (wise-rah does updates directly) const workflowTools = workflow?.tools;
if (workflowKey === 'integrate' && plannerTools.delegateToMiniRAH) { const SAFE_WORKFLOW_DEFAULT_TOOLS = [
delete plannerTools.delegateToMiniRAH; 'getNodesById', 'queryNodes', 'queryDimensionNodes', 'searchContentEmbeddings',
console.log('🚫 [WiseRAHExecutor] Removed delegateToMiniRAH for integrate workflow (direct updates only)'); 'webSearch', 'updateNode', 'createEdge'
} ];
const tools = workflowTools?.length
// For analysis-only tasks, also remove delegation ? getToolsByNames(workflowTools)
if (analysisOnly && plannerTools.delegateToMiniRAH) { : getToolsByNames(SAFE_WORKFLOW_DEFAULT_TOOLS);
delete plannerTools.delegateToMiniRAH;
} console.log('🛠️ [WorkflowExecutor] Tools for workflow:', Object.keys(tools));
console.log('🛠️ [WiseRAHExecutor] Planner tools retrieved:', Object.keys(plannerTools));
const toolsUsedInSession: string[] = []; const toolsUsedInSession: string[] = [];
const delegatedEdgeKeys = new Set<string>(); const delegatedEdgeKeys = new Set<string>();
// Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster // Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster
// No need to broadcast WORKFLOW_PROGRESS events to main chat anymore
const wrappedTools = Object.fromEntries( const wrappedTools = Object.fromEntries(
Object.entries(plannerTools).map(([name, tool]) => { Object.entries(tools).map(([name, tool]) => {
const wrapped = { const wrapped = {
...tool, ...tool,
async execute(params: any, context: any) { async execute(params: any, context: any) {
@@ -162,31 +149,10 @@ export class WiseRAHExecutor {
}) })
); );
// Enforce read-only constraint - remove write tools EXCEPT for workflows console.log('📝 [WorkflowExecutor] Starting execution loop...');
// Workflows need updateNode for content updates and createEdge for Quick Link workflow
const writeToolsToRemove = isWorkflow
? ['createNode', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
writeToolsToRemove.forEach(toolName => { const messages: ModelMessage[] = [
if (toolName in wrappedTools) { { role: 'system', content: WORKFLOW_EXECUTOR_SYSTEM_PROMPT },
console.warn(`WiseRAHExecutor: ${toolName} detected in planner toolset. Removing to enforce read-only constraint.`);
delete wrappedTools[toolName];
}
});
if (isWorkflow && 'updateNode' in wrappedTools) {
console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution');
}
if (isWorkflow && 'createEdge' in wrappedTools) {
console.log('✅ [WiseRAHExecutor] createEdge preserved for workflow execution');
}
console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools));
console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...');
const messages: CoreMessage[] = [
{ role: 'system', content: WISE_RAH_SYSTEM_PROMPT },
{ role: 'user', content: promptSections.join('\n\n') } { role: 'user', content: promptSections.join('\n\n') }
]; ];
@@ -196,21 +162,6 @@ export class WiseRAHExecutor {
const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: string }>(); const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: string }>();
const workerSummaries: string[] = []; const workerSummaries: string[] = [];
const workerSummarySet = new Set<string>();
let hasPlan = false;
let planReminderAdded = false;
let planIncludesDelegation = false;
let planRevisionNoticeSent = false;
let delegationNudgeSent = false;
let iterationsSincePlan = 0;
let lastPlanSummary = '';
let totalDelegations = 0;
let didCreateEdge = false;
let didUpdateNode = false;
const uniqueWebQueries = new Set<string>();
const uniqueEmbeddingQueries = new Set<string>();
let finalSummaryRequested = false;
let iterationsWithoutDelegation = 0;
const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : ''); const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
@@ -219,7 +170,7 @@ export class WiseRAHExecutor {
try { try {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} catch (error) { } catch (error) {
console.warn('[WiseRAHExecutor] Failed to serialize delegation payload', error); console.warn('[WorkflowExecutor] Failed to serialize delegation payload', error);
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
return undefined; return undefined;
} }
@@ -284,18 +235,18 @@ export class WiseRAHExecutor {
}); });
const finalStreamResult = await streamText({ const finalStreamResult = await streamText({
model: openaiProvider('gpt-5'), model: openaiProvider('gpt-5-mini'),
messages, messages,
tools: {}, tools: {},
maxOutputTokens: 500, maxOutputTokens: 500,
}); });
// Collect the complete response // Collect the complete response
const finalChunks: string[] = []; const finalChunks: string[] = [];
for await (const chunk of finalStreamResult.textStream) { for await (const chunk of finalStreamResult.textStream) {
finalChunks.push(chunk); finalChunks.push(chunk);
} }
const finalResponse = { const finalResponse = {
text: finalChunks.join(''), text: finalChunks.join(''),
usage: await finalStreamResult.usage, usage: await finalStreamResult.usage,
@@ -327,23 +278,23 @@ export class WiseRAHExecutor {
}; };
for (let i = 0; i < maxIterations; i++) { for (let i = 0; i < maxIterations; i++) {
console.log(`🔄 [WiseRAHExecutor] Iteration ${i + 1}/${maxIterations}`); console.log(`🔄 [WorkflowExecutor] Iteration ${i + 1}/${maxIterations}`);
// Touch delegation every iteration to prevent cleanup from killing it // Touch delegation every iteration to prevent cleanup from killing it
AgentDelegationService.touchDelegation(sessionId); AgentDelegationService.touchDelegation(sessionId);
const streamResult = await streamText({ const streamResult = await streamText({
model: openaiProvider('gpt-5'), model: openaiProvider('gpt-5-mini'),
messages, messages,
tools: wrappedTools, tools: wrappedTools,
}); });
// Collect the complete response // Collect the complete response
const chunks: string[] = []; const chunks: string[] = [];
for await (const chunk of streamResult.textStream) { for await (const chunk of streamResult.textStream) {
chunks.push(chunk); chunks.push(chunk);
} }
const response = { const response = {
text: chunks.join(''), text: chunks.join(''),
finishReason: await streamResult.finishReason, finishReason: await streamResult.finishReason,
@@ -355,7 +306,7 @@ export class WiseRAHExecutor {
totalUsage.outputTokens += response.usage?.outputTokens || 0; totalUsage.outputTokens += response.usage?.outputTokens || 0;
totalUsage.totalTokens += response.usage?.totalTokens || 0; totalUsage.totalTokens += response.usage?.totalTokens || 0;
console.log(`📊 [WiseRAHExecutor] Step ${i + 1} finishReason:`, response.finishReason); console.log(`📊 [WorkflowExecutor] Step ${i + 1} finishReason:`, response.finishReason);
// Stream text response to delegation chat // Stream text response to delegation chat
if (response.text && response.text.trim()) { if (response.text && response.text.trim()) {
@@ -367,13 +318,13 @@ export class WiseRAHExecutor {
if (response.finishReason !== 'tool-calls') { if (response.finishReason !== 'tool-calls') {
finalText = response.text; finalText = response.text;
console.log('✅ [WiseRAHExecutor] Got final text'); console.log('✅ [WorkflowExecutor] Got final text');
break; break;
} }
const toolCalls = response.toolCalls || []; const toolCalls = response.toolCalls || [];
console.log(`🔧 [WiseRAHExecutor] Executing ${toolCalls.length} tool calls`); console.log(`🔧 [WorkflowExecutor] Executing ${toolCalls.length} tool calls`);
// Broadcast new assistant message for next iteration // Broadcast new assistant message for next iteration
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
emitDelegationEvent({ type: 'assistant-message' }); emitDelegationEvent({ type: 'assistant-message' });
@@ -396,39 +347,16 @@ export class WiseRAHExecutor {
output: LanguageModelV2ToolResultOutput; output: LanguageModelV2ToolResultOutput;
}> = []; }> = [];
let executedTool = false;
for (const call of toolCalls) { for (const call of toolCalls) {
let callInputRaw = (call as any).input ?? (call as any).args; let callInputRaw = (call as any).input ?? (call as any).args;
// Append logic now handled in updateNode tool itself (lines 27-44)
const signatureInput = normaliseForSignature(call.toolName, callInputRaw); const signatureInput = normaliseForSignature(call.toolName, callInputRaw);
const signature = JSON.stringify({ tool: call.toolName, input: signatureInput }); const signature = JSON.stringify({ tool: call.toolName, input: signatureInput });
// Broadcast tool call to delegation stream // Broadcast tool call to delegation stream
emitToolStart(call.toolCallId, call.toolName, callInputRaw); emitToolStart(call.toolCallId, call.toolName, callInputRaw);
if (!hasPlan && call.toolName !== 'think') { // Skip duplicate tool calls (except think which can be called multiple times)
const warning = 'Planning required: use the think tool to outline a numbered plan (purpose, thoughts, next action) before other tools.';
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: warning },
});
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, warning, 'error', warning);
if (!planReminderAdded) {
planReminderAdded = true;
messages.push({
role: 'user',
content: 'Before calling other tools, use the think tool to draft a numbered plan and specify your next action.',
});
}
continue;
}
if (call.toolName !== 'think' && seenToolResults.has(signature)) { if (call.toolName !== 'think' && seenToolResults.has(signature)) {
const cached = seenToolResults.get(signature)!; const cached = seenToolResults.get(signature)!;
toolResults.push({ toolResults.push({
@@ -437,22 +365,15 @@ export class WiseRAHExecutor {
toolName: call.toolName, toolName: call.toolName,
output: cached.output, output: cached.output,
}); });
// Broadcast cached result // Broadcast cached result
emitToolCompletion(call.toolCallId, call.toolName, cached.summary || 'Cached result', cached.summary || 'Cached result'); emitToolCompletion(call.toolCallId, call.toolName, cached.summary || 'Cached result', cached.summary || 'Cached result');
if (call.toolName === 'delegateToMiniRAH' && cached.summary) {
if (!workerSummarySet.has(cached.summary)) {
workerSummarySet.add(cached.summary);
workerSummaries.push(`[reused] ${cached.summary}`);
}
}
continue; continue;
} }
const tool = wrappedTools[call.toolName]; const tool = wrappedTools[call.toolName];
if (!tool) { if (!tool) {
const warning = `Tool ${call.toolName} is not available to wise ra-h.`; const warning = `Tool ${call.toolName} is not available for this workflow.`;
toolResults.push({ toolResults.push({
type: 'tool-result', type: 'tool-result',
toolCallId: call.toolCallId, toolCallId: call.toolCallId,
@@ -477,46 +398,10 @@ export class WiseRAHExecutor {
}); });
emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete'); emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete');
if (call.toolName === 'think') { // Cache result (except think which can be called multiple times)
hasPlan = true; if (call.toolName !== 'think') {
lastPlanSummary = summary;
if (allowWrites) {
planIncludesDelegation = /delegate\b|delegateToMiniRAH|mini ra-h/i.test(summary);
} else {
planIncludesDelegation = false;
}
planRevisionNoticeSent = false;
delegationNudgeSent = false;
iterationsSincePlan = 0;
} else {
seenToolResults.set(signature, { output, summary }); seenToolResults.set(signature, { output, summary });
if (call.toolName === 'delegateToMiniRAH' && summary && !workerSummarySet.has(summary)) {
workerSummarySet.add(summary);
workerSummaries.push(summary);
totalDelegations += 1;
if (/Created edge/i.test(summary) || /Edge created/i.test(summary)) {
didCreateEdge = true;
}
if (/Updated node/i.test(summary) || /Appended/i.test(summary) || /content updated/i.test(summary)) {
didUpdateNode = true;
}
}
if (call.toolName === 'webSearch') {
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
if (query) {
uniqueWebQueries.add(query);
}
}
if (call.toolName === 'searchContentEmbeddings') {
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
if (query) {
uniqueEmbeddingQueries.add(query);
}
}
} }
executedTool = true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Tool execution failed'; const message = error instanceof Error ? error.message : 'Tool execution failed';
toolResults.push({ toolResults.push({
@@ -533,109 +418,47 @@ export class WiseRAHExecutor {
role: 'tool', role: 'tool',
content: toolResults, content: toolResults,
}); });
if (hasPlan) {
iterationsSincePlan += 1;
// Legacy delegation nudges removed - wise-rah completes workflows independently
}
const enoughMaterial = hasPlan && (allowWrites ? workerSummaries.length >= 2 : executedTool);
const tooManyDelegations = allowWrites && totalDelegations >= maxDelegationsAllowed && maxDelegationsAllowed > 0;
const webSearchCapReached = uniqueWebQueries.size >= maxDistinctWebSearches;
const embeddingCapReached = uniqueEmbeddingQueries.size >= maxDistinctEmbeddingSearches;
if (allowWrites && workflowKey === 'integrate' && totalDelegations === 0) {
iterationsWithoutDelegation += 1;
if (!delegationNudgeSent && iterationsWithoutDelegation >= 4) {
delegationNudgeSent = true;
const targetInstruction = workflowNodeId
? `Focus on node [NODE:${workflowNodeId}]. Delegate to mini ra-h now to (a) append integration insights to its content and (b) create edges to the highest-value related nodes you identified.`
: 'Delegate to mini ra-h now to (a) append integration insights to the focused node and (b) create edges to the highest-value related nodes you identified.';
messages.push({
role: 'user',
content: `${targetInstruction} Do not continue researching until those delegations are complete.`,
});
continue;
}
} else {
iterationsWithoutDelegation = 0;
}
if (allowWrites && workflowKey === 'integrate' && didCreateEdge && !didUpdateNode) {
messages.push({
role: 'user',
content: 'Edges are in place. Delegate to mini ra-h to append the Integration Insights section to the focused node before moving forward.',
});
continue;
}
const minIterationsBeforeSummary = allowWrites ? 2 : 1;
if (!finalSummaryRequested && (tooManyDelegations || webSearchCapReached || embeddingCapReached || (enoughMaterial && executedTool && i >= minIterationsBeforeSummary))) {
if (allowWrites && workflowKey === 'integrate' && !didUpdateNode) {
messages.push({
role: 'user',
content: 'Before summarizing, delegate to mini ra-h to append the Integration Insights content to the focused node.',
});
continue;
}
if (allowWrites && totalDelegations === 0) {
messages.push({
role: 'user',
content: 'You have not delegated any execution yet. Identify the concrete write actions required and call delegateToMiniRAH to perform them before summarising.',
});
continue;
}
let instruction = 'You have enough evidence from the workers. Provide the final Task/Actions/Result/Nodes/Follow-up summary now without further tool calls.';
if (tooManyDelegations) {
instruction = `You have already delegated the maximum allowed (${maxDelegationsAllowed}). Synthesize the findings now using the Task/Actions/Result/Nodes/Follow-up format. Do not call additional tools.`;
} else if (webSearchCapReached) {
instruction = `You have already issued about ${maxDistinctWebSearches} distinct web searches. Consolidate what you found into the final Task/Actions/Result/Nodes/Follow-up summary now—no additional tool calls.`;
} else if (embeddingCapReached) {
instruction = `Embedding searches have covered the knowledge base (limit ${maxDistinctEmbeddingSearches}). Switch to producing the Task/Actions/Result/Nodes/Follow-up summary—do not call further tools.`;
}
finalText = await requestFinalSummary(instruction);
finalSummaryRequested = true;
break;
}
} }
// If we hit max iterations without a final response, request one
if (!finalText) { if (!finalText) {
if (allowWrites && totalDelegations === 0) { console.warn('⚠️ [WorkflowExecutor] Max iterations hit with no summary. Requesting final response without tools.');
throw new Error('Wise ra-h attempted to summarize without delegating any execution to mini ra-h.'); finalText = await requestFinalSummary('Provide a brief summary of what was accomplished. Do not call any tools.');
} console.log('✅ [WorkflowExecutor] Final summary obtained after tool cutoff.');
console.warn('⚠️ [WiseRAHExecutor] Max iterations hit with no summary. Requesting final response without tools.');
finalText = await requestFinalSummary('You have gathered everything needed. Provide the final Task/Actions/Result/Nodes/Follow-up summary now. Do not call any tools.');
console.log('✅ [WiseRAHExecutor] Final summary obtained after tool cutoff.');
} }
const usage = totalUsage; const usage = totalUsage;
let summary = typeof finalText === 'string' ? finalText.trim() : ''; let summary = typeof finalText === 'string' ? finalText.trim() : '';
if (summary.length > 2000) { if (summary.length > 2000) {
console.log('⚠️ [WiseRAHExecutor] Summary too long, requesting concise version.'); console.log('⚠️ [WorkflowExecutor] Summary too long, requesting concise version.');
summary = (await requestFinalSummary('Condense the findings into ≤300 tokens using the Task/Actions/Result/Nodes/Follow-up format. Focus on the most salient insights and reference key nodes. Do not call any tools.')).trim(); summary = (await requestFinalSummary('Condense the findings into ≤300 tokens using the Task/Actions/Result/Nodes/Follow-up format. Focus on the most salient insights and reference key nodes. Do not call any tools.')).trim();
} }
if (summary.length > 1000) { if (summary.length > 1000) {
summary = `${summary.slice(0, 997)}`; summary = `${summary.slice(0, 997)}`;
} }
console.log('📄 [WiseRAHExecutor] Summary after trim:', summary); console.log('📄 [WorkflowExecutor] Summary after trim:', summary);
console.log('📏 [WiseRAHExecutor] Summary length:', summary.length); console.log('📏 [WorkflowExecutor] Summary length:', summary.length);
if (!summary) { if (!summary) {
emitDelegationEvent({ emitDelegationEvent({
type: 'assistant-message', type: 'assistant-message',
}); });
emitDelegationEvent({ emitDelegationEvent({
type: 'text-delta', type: 'text-delta',
delta: 'Wise ra-h attempted to summarise but the response was empty. Check tool logs above for context.', delta: 'Workflow executor attempted to summarise but the response was empty. Check tool logs above for context.',
}); });
throw new Error('Wise ra-h returned empty summary'); throw new Error('Workflow executor returned empty summary');
} }
console.log('[WiseRAHExecutor] summary:', summary); console.log('[WorkflowExecutor] summary:', summary);
// Emit final summary to the stream so it appears in the UI
emitDelegationEvent({ type: 'assistant-message' });
emitDelegationEvent({
type: 'text-delta',
delta: summary,
});
// Calculate cost and log to chats table // Calculate cost and log to chats table
if (usage) { if (usage) {
@@ -646,7 +469,7 @@ export class WiseRAHExecutor {
const costResult = calculateCost({ const costResult = calculateCost({
inputTokens, inputTokens,
outputTokens, outputTokens,
modelId: 'gpt-5', modelId: 'gpt-5-mini',
}); });
const usageData: UsageData = { const usageData: UsageData = {
@@ -654,7 +477,7 @@ export class WiseRAHExecutor {
outputTokens, outputTokens,
totalTokens, totalTokens,
estimatedCostUsd: costResult.totalCostUsd, estimatedCostUsd: costResult.totalCostUsd,
modelUsed: 'gpt-5', modelUsed: 'gpt-5-mini',
provider: 'openai', provider: 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined, toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined, toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
@@ -671,7 +494,7 @@ export class WiseRAHExecutor {
task, task,
summary, summary,
{ {
helperName: 'wise-rah', helperName: 'workflow-agent',
agentType: 'planner', agentType: 'planner',
delegationId: delegationId ?? null, delegationId: delegationId ?? null,
sessionId, sessionId,
@@ -680,32 +503,31 @@ export class WiseRAHExecutor {
parentChatId, parentChatId,
workflowKey, workflowKey,
workflowNodeId, workflowNodeId,
systemMessage: WISE_RAH_SYSTEM_PROMPT, systemMessage: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
backendUsage: [],
}, },
[] []
); );
console.log(`💰 [WiseRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`); console.log(`💰 [WorkflowExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
} }
console.log('✅ [WiseRAHExecutor] Completing delegation with summary'); console.log('✅ [WorkflowExecutor] Completing delegation with summary');
return AgentDelegationService.completeDelegation(sessionId, summary); return AgentDelegationService.completeDelegation(sessionId, summary);
} catch (error) { } catch (error) {
console.error('❌ [WiseRAHExecutor] Error during execution:', error); console.error('❌ [WorkflowExecutor] Error during execution:', error);
console.error('❌ [WiseRAHExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack'); console.error('❌ [WorkflowExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
const message = error instanceof Error ? error.message : 'Unknown delegation error'; const message = error instanceof Error ? error.message : 'Unknown delegation error';
// Broadcast error to delegation stream // Broadcast error to delegation stream
delegationStreamBroadcaster.broadcast(sessionId, { delegationStreamBroadcaster.broadcast(sessionId, {
type: 'assistant-message', type: 'assistant-message',
}); });
delegationStreamBroadcaster.broadcast(sessionId, { delegationStreamBroadcaster.broadcast(sessionId, {
type: 'text-delta', type: 'text-delta',
delta: `Wise ra-h failed: ${message}`, delta: `Workflow executor failed: ${message}`,
}); });
AgentDelegationService.completeDelegation(sessionId, `Wise ra-h failed: ${message}`, 'failed'); AgentDelegationService.completeDelegation(sessionId, `Workflow executor failed: ${message}`, 'failed');
throw error; throw error;
} }
} }
+25 -19
View File
@@ -3,7 +3,7 @@ import { AgentRegistry } from '@/services/agents/registry';
import { WorkflowRegistry } from '@/services/workflows/registry'; import { WorkflowRegistry } from '@/services/workflows/registry';
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry'; import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
import type { CacheableBlock, SystemPromptResult } from '@/types/prompts'; import type { CacheableBlock, SystemPromptResult } from '@/types/prompts';
import { buildAutoContextBlock } from '@/services/context/autoContext'; // buildAutoContextBlock removed - agent queries top nodes on-demand via sqliteQuery
export interface NodeContext { export interface NodeContext {
nodes: Node[]; nodes: Node[];
@@ -25,14 +25,27 @@ export interface ContextBuilderOptions {
} }
const BASE_CONTEXT = `=== RA-H BASE CONTEXT === const BASE_CONTEXT = `=== RA-H BASE CONTEXT ===
- Nodes store content (title, content, dimensions, metadata, link, chunk) You have access to the RA-H knowledge graph via two approaches:
- Edges capture directed relationships between nodes
- Dimensions organize nodes; locked dimensions (isPriority=true) auto-assign to new nodes **sqliteQuery tool** — Use for flexible read operations:
- You can create and manage dimensions using dimension tools (createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension) - Ad-hoc queries, exploration, complex JOINs, aggregations
- When auto-context is enabled you'll see BACKGROUND CONTEXT with the 10 most-connected nodes (ID + title only) - Any query pattern not covered by structured tools
- Focused nodes show truncated content; use queryNodes, searchContentEmbeddings, or queryEdge when you need full detail - Example: SELECT n.title, COUNT(e.id) FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id GROUP BY n.id
- Node references must use [NODE:id:"title"] so the UI renders clickable labels
- Pronouns or phrases like "this conversation/paper/video" refer to the primary focused node unless clarified **Structured tools** — Use for these specific cases:
- Writes: createNode, updateNode, createEdge (need validation, embeddings, hooks)
- Semantic search: searchContentEmbeddings (needs vector DB)
- External APIs: webSearch, youtubeExtract, websiteExtract, paperExtract
**Schema Quick Reference:**
- nodes: id, title, content, chunk, dimensions (JSON array), url, created_at
- edges: id, from_node_id, to_node_id, context (JSON), source, explanation
- dimensions: id, name, description, is_locked
**Other Context:**
- Node references: use [NODE:id:"title"] format for clickable UI labels
- Focused nodes show truncated content; query for full detail
- "this conversation/paper/video" refers to the primary focused node
`; `;
function buildStaticBaseContext(): string { function buildStaticBaseContext(): string {
@@ -243,16 +256,9 @@ export async function buildSystemPromptBlocks(
}); });
} }
const autoContextBlock = isPrimaryOrchestrator(helperComponentKey) // REMOVED: autoContextBlock (top 10 nodes)
? buildAutoContextBlock() // Agent can now query this on-demand via sqliteQuery:
: null; // SELECT id, title, COUNT(e.id) as edges FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id GROUP BY n.id ORDER BY edges DESC LIMIT 10
if (autoContextBlock && autoContextBlock.trim().length > 0) {
blocks.push({
type: 'text',
text: autoContextBlock,
...(cacheControl ? { cache_control: cacheControl } : {})
});
}
// Add dimension context if an active dimension is provided // Add dimension context if an active dimension is provided
if (nodeContext.activeDimension) { if (nodeContext.activeDimension) {
+12
View File
@@ -18,6 +18,8 @@ const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
requiresFocusedNode: true, requiresFocusedNode: true,
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: 'Brief section appended with what/gist/why it matters', expectedOutcome: 'Brief section appended with what/gist/why it matters',
tools: ['getNodesById', 'updateNode'],
maxIterations: 3,
}, },
'research': { 'research': {
id: 2, id: 2,
@@ -29,6 +31,8 @@ const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
requiresFocusedNode: true, requiresFocusedNode: true,
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: 'Research notes appended with background and key findings', expectedOutcome: 'Research notes appended with background and key findings',
tools: ['getNodesById', 'webSearch', 'updateNode'],
maxIterations: 5,
}, },
'connect': { 'connect': {
id: 3, id: 3,
@@ -40,6 +44,8 @@ const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
requiresFocusedNode: true, requiresFocusedNode: true,
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: '3-5 edges created to related nodes', expectedOutcome: '3-5 edges created to related nodes',
tools: ['getNodesById', 'queryNodes', 'createEdge'],
maxIterations: 6,
}, },
'integrate': { 'integrate': {
id: 4, id: 4,
@@ -51,6 +57,8 @@ const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
requiresFocusedNode: true, requiresFocusedNode: true,
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: 'Integration analysis appended; 3-5 edges created', expectedOutcome: 'Integration analysis appended; 3-5 edges created',
tools: ['getNodesById', 'queryNodes', 'searchContentEmbeddings', 'createEdge', 'updateNode'],
maxIterations: 12,
}, },
'survey': { 'survey': {
id: 5, id: 5,
@@ -62,6 +70,8 @@ const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
requiresFocusedNode: false, // Requires active dimension, not focused node requiresFocusedNode: false, // Requires active dimension, not focused node
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: 'Dimension description updated with survey findings', expectedOutcome: 'Dimension description updated with survey findings',
tools: ['queryDimensionNodes', 'searchContentEmbeddings', 'updateDimension'],
maxIterations: 5,
}, },
}; };
@@ -80,6 +90,8 @@ function userWorkflowToDefinition(uw: ReturnType<typeof loadUserWorkflow>, id: n
requiresFocusedNode: uw.requiresFocusedNode, requiresFocusedNode: uw.requiresFocusedNode,
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: undefined, expectedOutcome: undefined,
tools: uw.tools,
maxIterations: uw.maxIterations,
}; };
} }
+4
View File
@@ -8,4 +8,8 @@ export interface WorkflowDefinition {
requiresFocusedNode: boolean; requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main'; primaryActor: 'oracle' | 'main';
expectedOutcome?: string; expectedOutcome?: string;
/** Tools this workflow is allowed to use. If not specified, uses default set. */
tools?: string[];
/** Maximum iterations for this workflow. Defaults to 10. */
maxIterations?: number;
} }
+11 -1
View File
@@ -9,6 +9,8 @@ export interface UserWorkflow {
instructions: string; instructions: string;
enabled: boolean; enabled: boolean;
requiresFocusedNode: boolean; requiresFocusedNode: boolean;
tools?: string[];
maxIterations?: number;
} }
function resolveBaseConfigDir(): string { function resolveBaseConfigDir(): string {
@@ -66,6 +68,8 @@ export function listUserWorkflows(): UserWorkflow[] {
instructions: parsed.instructions, instructions: parsed.instructions,
enabled: parsed.enabled !== false, enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false, requiresFocusedNode: parsed.requiresFocusedNode !== false,
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
}); });
} }
} catch (error) { } catch (error) {
@@ -99,6 +103,8 @@ export function loadUserWorkflow(key: string): UserWorkflow | null {
instructions: parsed.instructions, instructions: parsed.instructions,
enabled: parsed.enabled !== false, enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false, requiresFocusedNode: parsed.requiresFocusedNode !== false,
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
}; };
} catch (error) { } catch (error) {
console.warn(`Failed to load workflow ${key}:`, error); console.warn(`Failed to load workflow ${key}:`, error);
@@ -110,7 +116,7 @@ export function saveWorkflow(workflow: UserWorkflow): void {
ensureWorkflowsDirExists(); ensureWorkflowsDirExists();
const filePath = path.join(getWorkflowsDir(), `${workflow.key}.json`); const filePath = path.join(getWorkflowsDir(), `${workflow.key}.json`);
const data = { const data: Record<string, unknown> = {
key: workflow.key, key: workflow.key,
displayName: workflow.displayName, displayName: workflow.displayName,
description: workflow.description, description: workflow.description,
@@ -119,6 +125,10 @@ export function saveWorkflow(workflow: UserWorkflow): void {
requiresFocusedNode: workflow.requiresFocusedNode, requiresFocusedNode: workflow.requiresFocusedNode,
}; };
// Only include tools/maxIterations if defined
if (workflow.tools) data.tools = workflow.tools;
if (workflow.maxIterations) data.maxIterations = workflow.maxIterations;
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} }
+1 -4
View File
@@ -42,12 +42,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
queryDimensionNodes: 'core', queryDimensionNodes: 'core',
searchContentEmbeddings: 'core', searchContentEmbeddings: 'core',
// Orchestration: Delegation and reasoning (orchestrator only) // Orchestration: Workflows and reasoning (orchestrator only)
webSearch: 'orchestration', webSearch: 'orchestration',
think: 'orchestration', think: 'orchestration',
delegateToMiniRAH: 'orchestration',
delegateNodeQuotes: 'orchestration',
delegateNodeComparison: 'orchestration',
delegateToWiseRAH: 'orchestration', delegateToWiseRAH: 'orchestration',
executeWorkflow: 'orchestration', executeWorkflow: 'orchestration',
listWorkflows: 'orchestration', listWorkflows: 'orchestration',
+21 -21
View File
@@ -9,8 +9,7 @@ import { updateEdgeTool } from '../database/updateEdge';
import { quickLinkTool } from '../database/quickLink'; import { quickLinkTool } from '../database/quickLink';
import { createDimensionTool } from '../database/createDimension'; import { createDimensionTool } from '../database/createDimension';
import { updateDimensionTool } from '../database/updateDimension'; import { updateDimensionTool } from '../database/updateDimension';
import { lockDimensionTool } from '../database/lockDimension'; // lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
import { unlockDimensionTool } from '../database/unlockDimension';
import { deleteDimensionTool } from '../database/deleteDimension'; import { deleteDimensionTool } from '../database/deleteDimension';
import { queryDimensionsTool } from '../database/queryDimensions'; import { queryDimensionsTool } from '../database/queryDimensions';
import { getDimensionTool } from '../database/getDimension'; import { getDimensionTool } from '../database/getDimension';
@@ -18,8 +17,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings'; import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
import { webSearchTool } from '../other/webSearch'; import { webSearchTool } from '../other/webSearch';
import { thinkTool } from '../other/think'; import { thinkTool } from '../other/think';
import { delegateToMiniRAHTool } from '../orchestration/delegateToMiniRAH';
import { delegateNodeQuotesTool, delegateNodeComparisonTool } from '../orchestration/delegationHelpers';
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH'; import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
import { executeWorkflowTool } from '../orchestration/executeWorkflow'; import { executeWorkflowTool } from '../orchestration/executeWorkflow';
import { listWorkflowsTool } from '../orchestration/listWorkflows'; import { listWorkflowsTool } from '../orchestration/listWorkflows';
@@ -28,10 +25,12 @@ import { editWorkflowTool } from '../orchestration/editWorkflow';
import { youtubeExtractTool } from '../other/youtubeExtract'; import { youtubeExtractTool } from '../other/youtubeExtract';
import { websiteExtractTool } from '../other/websiteExtract'; import { websiteExtractTool } from '../other/websiteExtract';
import { paperExtractTool } from '../other/paperExtract'; import { paperExtractTool } from '../other/paperExtract';
import { sqliteQueryTool } from '../other/sqliteQuery';
import { logEvalToolCall } from '@/services/evals/evalsLogger'; import { logEvalToolCall } from '@/services/evals/evalsLogger';
// Core tools available to all agents (read-only graph operations) // Core tools available to all agents (read-only graph operations)
const CORE_TOOLS: Record<string, any> = { const CORE_TOOLS: Record<string, any> = {
sqliteQuery: sqliteQueryTool,
queryNodes: queryNodesTool, queryNodes: queryNodesTool,
getNodesById: getNodesByIdTool, getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool, queryEdge: queryEdgeTool,
@@ -44,9 +43,6 @@ const CORE_TOOLS: Record<string, any> = {
const ORCHESTRATION_TOOLS: Record<string, any> = { const ORCHESTRATION_TOOLS: Record<string, any> = {
webSearch: webSearchTool, webSearch: webSearchTool,
think: thinkTool, think: thinkTool,
delegateToMiniRAH: delegateToMiniRAHTool,
delegateNodeQuotes: delegateNodeQuotesTool,
delegateNodeComparison: delegateNodeComparisonTool,
delegateToWiseRAH: delegateToWiseRAHTool, delegateToWiseRAH: delegateToWiseRAHTool,
executeWorkflow: executeWorkflowTool, executeWorkflow: executeWorkflowTool,
listWorkflows: listWorkflowsTool, listWorkflows: listWorkflowsTool,
@@ -63,8 +59,6 @@ const EXECUTION_TOOLS: Record<string, any> = {
quickLink: quickLinkTool, quickLink: quickLinkTool,
createDimension: createDimensionTool, createDimension: createDimensionTool,
updateDimension: updateDimensionTool, updateDimension: updateDimensionTool,
lockDimension: lockDimensionTool,
unlockDimension: unlockDimensionTool,
deleteDimension: deleteDimensionTool, deleteDimension: deleteDimensionTool,
youtubeExtract: youtubeExtractTool, youtubeExtract: youtubeExtractTool,
websiteExtract: websiteExtractTool, websiteExtract: websiteExtractTool,
@@ -98,8 +92,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
'quickLink', 'quickLink',
'createDimension', 'createDimension',
'updateDimension', 'updateDimension',
'lockDimension',
'unlockDimension',
'deleteDimension', 'deleteDimension',
'youtubeExtract', 'youtubeExtract',
'websiteExtract', 'websiteExtract',
@@ -108,25 +100,22 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
const EXECUTOR_TOOL_NAMES = [ const EXECUTOR_TOOL_NAMES = [
...Object.keys(CORE_TOOLS), ...Object.keys(CORE_TOOLS),
...Object.keys(ORCHESTRATION_TOOLS).filter(name => ...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
name !== 'delegateToMiniRAH' &&
name !== 'delegateToWiseRAH' && name !== 'delegateToWiseRAH' &&
name !== 'executeWorkflow' && name !== 'executeWorkflow'
name !== 'delegateNodeQuotes' &&
name !== 'delegateNodeComparison'
), ),
...Object.keys(EXECUTION_TOOLS), ...Object.keys(EXECUTION_TOOLS),
]; ];
// Note: PLANNER_TOOL_NAMES kept for backwards compatibility but workflows now use specific tool sets
const PLANNER_TOOL_NAMES = [ const PLANNER_TOOL_NAMES = [
...Object.keys(CORE_TOOLS), ...Object.keys(CORE_TOOLS),
'webSearch', 'webSearch',
'think', 'think',
'delegateToMiniRAH', 'updateNode',
'updateNode', // For workflow execution (integrate workflow needs direct write access) 'createEdge',
'createEdge', // For edge creation in workflows 'quickLink',
'quickLink', // Fast edge creation via text search 'updateDimension',
'updateDimension', // For survey workflow (dimension analysis)
]; ];
/** /**
@@ -196,6 +185,17 @@ export function getToolsForRole(role: 'orchestrator' | 'executor' | 'planner'):
return getHelperTools(names); 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 * 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}`;
},
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { AgentDelegationService } from '@/services/agents/delegation'; 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 { RequestContext } from '@/services/context/requestContext';
export const delegateToWiseRAHTool = tool({ export const delegateToWiseRAHTool = tool({
@@ -25,7 +25,7 @@ export const delegateToWiseRAHTool = tool({
supabaseToken: null, supabaseToken: null,
}); });
const execution = await WiseRAHExecutor.execute({ const execution = await WorkflowExecutor.execute({
sessionId: delegation.sessionId, sessionId: delegation.sessionId,
task, task,
context, 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);
}
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { WorkflowRegistry } from '@/services/workflows/registry'; import { WorkflowRegistry } from '@/services/workflows/registry';
import { getSQLiteClient } from '@/services/database/sqlite-client'; import { getSQLiteClient } from '@/services/database/sqlite-client';
import { AgentDelegationService } from '@/services/agents/delegation'; 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 { RequestContext } from '@/services/context/requestContext';
import { getAutoContextSummaries } from '@/services/context/autoContext'; 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 // Fire-and-forget execution so main ra-h stays responsive while workflow runs
void WiseRAHExecutor.execute({ void WorkflowExecutor.execute({
sessionId: delegation.sessionId, sessionId: delegation.sessionId,
task, task,
context: contextLines, context: contextLines,
+145
View File
@@ -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,
};
}
},
});