Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||
export type DelegationAgentType = 'mini' | 'wise-rah';
|
||||
|
||||
export interface AgentDelegation {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
status: DelegationStatus;
|
||||
summary?: string | null;
|
||||
agentType: DelegationAgentType;
|
||||
supabaseToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function rowToDelegation(row: any): AgentDelegation {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
task: row.task,
|
||||
context: (() => {
|
||||
try {
|
||||
return row.context ? JSON.parse(row.context) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
expectedOutcome: row.expected_outcome,
|
||||
status: row.status as DelegationStatus,
|
||||
summary: row.summary,
|
||||
agentType: (row.agent_type || 'mini') as DelegationAgentType,
|
||||
supabaseToken: row.supabase_token ?? null,
|
||||
// SQLite CURRENT_TIMESTAMP is UTC, append 'Z' to parse correctly as UTC
|
||||
createdAt: row.created_at ? row.created_at.replace(' ', 'T') + 'Z' : row.created_at,
|
||||
updatedAt: row.updated_at ? row.updated_at.replace(' ', 'T') + 'Z' : row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTable() {
|
||||
const db = getSQLiteClient();
|
||||
db.query(`
|
||||
CREATE TABLE IF NOT EXISTS agent_delegations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
context TEXT,
|
||||
expected_outcome TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
summary TEXT,
|
||||
agent_type TEXT NOT NULL DEFAULT 'mini',
|
||||
supabase_token TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Add agent_type column if it doesn't exist (migration)
|
||||
try {
|
||||
const stmt = db.prepare('SELECT 1 FROM agent_delegations LIMIT 0');
|
||||
const tableExists = stmt !== null;
|
||||
|
||||
if (tableExists) {
|
||||
// Try to add the column, ignore if it already exists
|
||||
try {
|
||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'mini'`).run();
|
||||
console.log('✅ Added agent_type column to agent_delegations table');
|
||||
} catch (alterError: any) {
|
||||
// Column already exists, ignore
|
||||
if (!alterError.message?.includes('duplicate column')) {
|
||||
console.warn('Migration warning:', alterError.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN supabase_token TEXT`).run();
|
||||
console.log('✅ Added supabase_token column to agent_delegations table');
|
||||
} catch (alterError: any) {
|
||||
if (!alterError.message?.includes('duplicate column')) {
|
||||
console.warn('Migration warning:', alterError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Table doesn't exist yet or other error - it will be created with the columns
|
||||
console.log('Table creation will include agent_type and supabase_token columns');
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentDelegationService {
|
||||
static createDelegation(input: {
|
||||
task: string;
|
||||
context?: string[];
|
||||
expectedOutcome?: string | null;
|
||||
agentType?: DelegationAgentType;
|
||||
supabaseToken?: string | null;
|
||||
}): AgentDelegation {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const sessionId = `delegation_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const contextJson = JSON.stringify(input.context ?? []);
|
||||
const agentType = input.agentType || 'mini';
|
||||
db.prepare(
|
||||
`INSERT INTO agent_delegations (session_id, task, context, expected_outcome, status, agent_type, supabase_token)
|
||||
VALUES (?, ?, ?, ?, 'queued', ?, ?)`
|
||||
).run(
|
||||
sessionId,
|
||||
input.task,
|
||||
contextJson,
|
||||
input.expectedOutcome ?? null,
|
||||
agentType,
|
||||
input.supabaseToken ?? null
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT * FROM agent_delegations WHERE session_id = ?')
|
||||
.get(sessionId);
|
||||
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_CREATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static markInProgress(sessionId: string): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations
|
||||
SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ? AND status = 'queued'`
|
||||
).run(sessionId);
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
if (!row) return null;
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static touchDelegation(sessionId: string): void {
|
||||
// Update the timestamp to prevent cleanup from killing active delegations
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ? AND status = 'in_progress'`
|
||||
).run(sessionId);
|
||||
}
|
||||
|
||||
static completeDelegation(sessionId: string, summary: string, status: DelegationStatus = 'completed'): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations
|
||||
SET status = ?, summary = ?, updated_at = CURRENT_TIMESTAMP, supabase_token = NULL
|
||||
WHERE session_id = ?`
|
||||
).run(status, summary, sessionId);
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
if (!row) return null;
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static getBySessionId(sessionId: string): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
return row ? rowToDelegation(row) : null;
|
||||
}
|
||||
|
||||
static getDelegation(sessionId: string): AgentDelegation | null {
|
||||
return this.getBySessionId(sessionId);
|
||||
}
|
||||
|
||||
static listActive({ includeCompleted = true, limit = 100 }: { includeCompleted?: boolean; limit?: number } = {}): AgentDelegation[] {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
// Load all delegations - user closes them manually from UI
|
||||
const rows = includeCompleted
|
||||
? db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit)
|
||||
: db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
WHERE status IN ('queued','in_progress')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit);
|
||||
return rows.map(rowToDelegation);
|
||||
}
|
||||
|
||||
static listRecent(limit = 20): AgentDelegation[] {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit);
|
||||
return rows.map(rowToDelegation);
|
||||
}
|
||||
|
||||
static deleteDelegation(sessionId: string): boolean {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const result = db.prepare('DELETE FROM agent_delegations WHERE session_id = ?').run(sessionId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static cleanupStaleDelegations(timeoutMinutes = 15): number {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE agent_delegations
|
||||
SET status = 'failed',
|
||||
summary = 'Task timed out (exceeded ${timeoutMinutes} minutes)',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'in_progress'
|
||||
AND updated_at < ?
|
||||
`).run(cutoffTime);
|
||||
|
||||
const affectedCount = result.changes || 0;
|
||||
|
||||
if (affectedCount > 0) {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM agent_delegations WHERE status = 'failed' AND summary LIKE 'Task timed out%'`
|
||||
).all();
|
||||
rows.forEach(row => {
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
});
|
||||
}
|
||||
|
||||
return affectedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
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';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
|
||||
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 requestContext = RequestContext.get();
|
||||
const delegateKey =
|
||||
requestContext.apiKeys?.openai ||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { AgentDelegationService } from './delegation';
|
||||
import { summarizeToolExecution } from './toolResultUtils';
|
||||
import { youtubeExtractTool } from '@/tools/other/youtubeExtract';
|
||||
import { websiteExtractTool } from '@/tools/other/websiteExtract';
|
||||
import { paperExtractTool } from '@/tools/other/paperExtract';
|
||||
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||
import { summarizeTranscript } from './transcriptSummarizer';
|
||||
|
||||
export type QuickAddMode = 'link' | 'note' | 'chat';
|
||||
|
||||
export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
|
||||
|
||||
export interface QuickAddInput {
|
||||
rawInput: string;
|
||||
mode?: QuickAddMode;
|
||||
}
|
||||
|
||||
function isLikelyChatTranscript(raw: string): boolean {
|
||||
const newlineCount = (raw.match(/\n/g)?.length ?? 0);
|
||||
if (newlineCount >= 3 && raw.length > 300) return true;
|
||||
if (/\b\d{1,2}:\d{2}\b/.test(raw) && newlineCount >= 1) return true;
|
||||
if (/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(raw)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detectInputType(raw: string, mode?: QuickAddMode): QuickAddInputType {
|
||||
if (mode === 'chat') return 'chat';
|
||||
if (mode === 'note') return 'note';
|
||||
|
||||
const input = raw.trim();
|
||||
if (/youtu(\.be|be\.com)/i.test(input)) return 'youtube';
|
||||
if (/\.pdf($|\?)/i.test(input) || /arxiv\.org\//i.test(input)) return 'pdf';
|
||||
if (/^https?:\/\//i.test(input)) return 'website';
|
||||
if (!mode && isLikelyChatTranscript(input)) return 'chat';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function buildTaskPrompt(type: QuickAddInputType, input: string): string {
|
||||
switch (type) {
|
||||
case 'youtube':
|
||||
return `Quick Add: extract YouTube video and create node → ${input}`;
|
||||
case 'website':
|
||||
return `Quick Add: extract webpage and create node → ${input}`;
|
||||
case 'pdf':
|
||||
return `Quick Add: extract PDF and create node → ${input}`;
|
||||
case 'note':
|
||||
return `Quick Add note: create a node from this text with no dimensions → ${input}`;
|
||||
case 'chat':
|
||||
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildExpectedOutcome(type: QuickAddInputType): string {
|
||||
if (type === 'note') {
|
||||
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
|
||||
}
|
||||
if (type === 'chat') {
|
||||
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
|
||||
}
|
||||
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
|
||||
}
|
||||
|
||||
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
|
||||
|
||||
const EXTRACTION_TOOL_MAP = {
|
||||
youtube: { toolName: 'youtubeExtract' as const, execute: youtubeExtractTool.execute },
|
||||
website: { toolName: 'websiteExtract' as const, execute: websiteExtractTool.execute },
|
||||
pdf: { toolName: 'paperExtract' as const, execute: paperExtractTool.execute },
|
||||
};
|
||||
|
||||
interface SummaryParts {
|
||||
task: string;
|
||||
action: string;
|
||||
resultMessage: string;
|
||||
nodeReference: string;
|
||||
}
|
||||
|
||||
interface ExtractionToolResultData {
|
||||
nodeId?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ExtractionToolResult {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
data?: ExtractionToolResultData | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface CreateNodeResponse {
|
||||
success?: boolean;
|
||||
data?: { id?: number; title?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildStructuredSummary({ task, action, resultMessage, nodeReference }: SummaryParts): string {
|
||||
const normalizedResult = resultMessage?.trim().length ? resultMessage.trim() : `${action} completed.`;
|
||||
const normalizedNode = nodeReference || 'None';
|
||||
return [
|
||||
`Task: ${task}`,
|
||||
`Actions: ${action}`,
|
||||
`Result: ${normalizedResult}`,
|
||||
`Node: ${normalizedNode}`,
|
||||
'Context sources used: None',
|
||||
'Follow-up: None',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function deriveNoteTitle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return 'Quick Add Note';
|
||||
}
|
||||
const sentenceMatch = trimmed.match(/^(.{1,120}?)([.!?]\s|\n|$)/);
|
||||
const candidate = sentenceMatch ? sentenceMatch[1] : trimmed.slice(0, 120);
|
||||
const title = candidate.replace(/\s+/g, ' ').trim();
|
||||
return title.length >= trimmed.length || title.length <= 120 ? title : `${title.slice(0, 117)}…`;
|
||||
}
|
||||
|
||||
function deriveChatTitle(raw: string, summarySubject?: string): string {
|
||||
if (summarySubject && summarySubject.trim().length > 0) {
|
||||
return summarySubject.trim();
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return 'Chat Transcript';
|
||||
const firstLine = trimmed.split('\n')[0];
|
||||
const cleaned = firstLine.replace(/You said:|ChatGPT said:|Claude said:/gi, '').trim();
|
||||
if (!cleaned) return 'Chat Transcript';
|
||||
return cleaned.length > 120 ? `${cleaned.slice(0, 117)}…` : cleaned;
|
||||
}
|
||||
|
||||
function isExtractionToolResult(value: unknown): value is ExtractionToolResult {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
|
||||
return false;
|
||||
}
|
||||
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
|
||||
return false;
|
||||
}
|
||||
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
|
||||
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
|
||||
if (!execute) {
|
||||
throw new Error(`Tool ${toolName} does not have an execute function`);
|
||||
}
|
||||
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
|
||||
|
||||
if (!isExtractionToolResult(rawResult)) {
|
||||
throw new Error(`Unexpected response from ${toolName}`);
|
||||
}
|
||||
|
||||
const toolResult = rawResult;
|
||||
|
||||
if (!toolResult || toolResult.success === false) {
|
||||
const errorMessage = toolResult?.error || `Failed to execute ${toolName}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const summaryLine = summarizeToolExecution(toolName, { url }, toolResult);
|
||||
const nodeId = toolResult.data?.nodeId;
|
||||
const nodeTitle = typeof toolResult.data?.title === 'string' && toolResult.data.title.trim().length > 0
|
||||
? toolResult.data.title.trim()
|
||||
: nodeId ? `Node ${nodeId}` : 'Created node';
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title: nodeTitle }) : 'None';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: toolName,
|
||||
resultMessage: summaryLine,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
const content = rawInput.trim();
|
||||
if (!content) {
|
||||
throw new Error('Input is required to create a note');
|
||||
}
|
||||
|
||||
const title = deriveNoteTitle(content);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
dimensions: [],
|
||||
metadata: {
|
||||
source: 'quick-add-note',
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rawResult = await response.json();
|
||||
|
||||
if (!isCreateNodeResponse(rawResult)) {
|
||||
throw new Error('Unexpected response from node creation');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(rawResult?.error || 'Failed to create note');
|
||||
}
|
||||
|
||||
const nodeId = rawResult?.data?.id;
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
|
||||
const resultMessage = nodeId ? `Created note ${nodeReference}.` : 'Created note.';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: 'createNode',
|
||||
resultMessage,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
const transcript = rawInput.trim();
|
||||
if (!transcript) {
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
}
|
||||
|
||||
const summaryResult = await summarizeTranscript(transcript);
|
||||
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
|
||||
|
||||
const intentLine = summaryResult.intent?.trim();
|
||||
const progressLine = summaryResult.progress?.trim();
|
||||
const stickingPoints = summaryResult.stickingPoints || [];
|
||||
|
||||
const highlightSection = (summaryResult.highlights?.length ?? 0) > 0
|
||||
? ['Highlights:', ...summaryResult.highlights!.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const followUpSection = (summaryResult.openQuestions?.length ?? 0) > 0
|
||||
? ['Open Questions:', ...summaryResult.openQuestions!.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const stickingSection = stickingPoints.length > 0
|
||||
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const contentParts = [
|
||||
`Overview:\n${baseSummary}`,
|
||||
];
|
||||
|
||||
if (intentLine) {
|
||||
contentParts.push(`What you were trying to do:\n${intentLine}`);
|
||||
}
|
||||
if (progressLine) {
|
||||
contentParts.push(`Where you made progress:\n${progressLine}`);
|
||||
}
|
||||
if (stickingSection) {
|
||||
contentParts.push(stickingSection);
|
||||
}
|
||||
if (highlightSection) contentParts.push(highlightSection);
|
||||
if (followUpSection) contentParts.push(followUpSection);
|
||||
const content = contentParts.join('\n\n');
|
||||
|
||||
const title = deriveChatTitle(transcript, summaryResult.subject);
|
||||
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
const metadata = {
|
||||
source: 'quick-add-chat',
|
||||
summary_subject: summaryResult.subject,
|
||||
summary_intent: summaryResult.intent,
|
||||
summary_progress: summaryResult.progress,
|
||||
highlights: summaryResult.highlights ?? [],
|
||||
open_questions: summaryResult.openQuestions ?? [],
|
||||
participants: summaryResult.participants ?? [],
|
||||
sticking_points: summaryResult.stickingPoints ?? [],
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
chunk: transcript,
|
||||
dimensions: [],
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawResult = await response.json();
|
||||
|
||||
if (!isCreateNodeResponse(rawResult)) {
|
||||
throw new Error('Unexpected response from node creation');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(rawResult?.error || 'Failed to create chat transcript node');
|
||||
}
|
||||
|
||||
const nodeId = rawResult?.data?.id;
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
|
||||
const resultMessage = nodeId ? `Created chat transcript ${nodeReference}.` : 'Created chat transcript.';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: 'chatTranscriptImport',
|
||||
resultMessage,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
|
||||
const inputType = detectInputType(rawInput, mode);
|
||||
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
|
||||
const task = buildTaskPrompt(inputType, rawInput);
|
||||
const expectedOutcome = buildExpectedOutcome(inputType);
|
||||
|
||||
const delegation = AgentDelegationService.createDelegation({
|
||||
task,
|
||||
context,
|
||||
expectedOutcome,
|
||||
});
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
AgentDelegationService.markInProgress(delegation.sessionId);
|
||||
|
||||
let summary: string;
|
||||
if (inputType === 'note') {
|
||||
summary = await handleNoteQuickAdd(rawInput, task);
|
||||
} else if (inputType === 'chat') {
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
} else {
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
}
|
||||
|
||||
AgentDelegationService.completeDelegation(delegation.sessionId, summary, 'completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
AgentDelegationService.completeDelegation(
|
||||
delegation.sessionId,
|
||||
`Quick Add failed: ${message}`,
|
||||
'failed'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return delegation;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
||||
import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main';
|
||||
import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy';
|
||||
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
|
||||
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
|
||||
import type { AgentDefinition } from './types';
|
||||
|
||||
/**
|
||||
* Code-first agent registry (opinionated, not database-driven)
|
||||
* Agents are defined in code and cannot be modified by users
|
||||
*/
|
||||
export class AgentRegistry {
|
||||
// Deterministic agent definitions baked into code
|
||||
private static readonly AGENTS: Record<string, AgentDefinition> = {
|
||||
'ra-h': {
|
||||
id: 1,
|
||||
key: 'ra-h',
|
||||
displayName: 'ra-h (hard)',
|
||||
description: 'Opinionated orchestrator agent',
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
role: 'orchestrator',
|
||||
systemPrompt: RAH_MAIN_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'ra-h-easy': {
|
||||
id: 4,
|
||||
key: 'ra-h-easy',
|
||||
displayName: 'ra-h (easy)',
|
||||
description: 'Fast, low-latency orchestrator',
|
||||
model: 'openai/gpt-5-mini',
|
||||
role: 'orchestrator',
|
||||
systemPrompt: RAH_EASY_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'mini-rah': {
|
||||
id: 2,
|
||||
key: 'mini-rah',
|
||||
displayName: 'mini ra-h',
|
||||
description: 'Executor agent for delegated tasks',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
role: 'executor',
|
||||
systemPrompt: MINI_RAH_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('executor'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'wise-rah': {
|
||||
id: 3,
|
||||
key: 'wise-rah',
|
||||
displayName: 'wise ra-h',
|
||||
description: 'Complex workflow planner and orchestrator',
|
||||
model: 'openai/gpt-5',
|
||||
role: 'planner',
|
||||
systemPrompt: WISE_RAH_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('planner'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
}
|
||||
};
|
||||
|
||||
static async getAgentByKey(key: string): Promise<AgentDefinition | null> {
|
||||
return this.AGENTS[key] || null;
|
||||
}
|
||||
|
||||
static async getAgentById(id: number): Promise<AgentDefinition | null> {
|
||||
return Object.values(this.AGENTS).find(a => a.id === id) || null;
|
||||
}
|
||||
|
||||
static async getEnabledAgents(): Promise<AgentDefinition[]> {
|
||||
return Object.values(this.AGENTS).filter(a => a.enabled);
|
||||
}
|
||||
|
||||
static async orchestrator(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['ra-h'];
|
||||
}
|
||||
|
||||
static async orchestratorForMode(mode: 'easy' | 'hard' = 'easy'): Promise<AgentDefinition> {
|
||||
if (mode === 'hard') {
|
||||
return this.AGENTS['ra-h'];
|
||||
}
|
||||
return this.AGENTS['ra-h-easy'];
|
||||
}
|
||||
|
||||
static async executor(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['mini-rah'];
|
||||
}
|
||||
|
||||
static async planner(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['wise-rah'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
function ensureString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function ensureNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
function truncateOrDefault(value: string, limit = 180, fallback = ''): string {
|
||||
if (!value) return fallback;
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
|
||||
export function summarizeToolExecution(toolName: string, args: any, result: any): string {
|
||||
const fallback = `${toolName} completed.`;
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (result.success === false) {
|
||||
const error = ensureString(result.error) || 'unknown error';
|
||||
return `${toolName} failed: ${error}`;
|
||||
}
|
||||
|
||||
const message = ensureString((result as any).message);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (toolName === 'think') {
|
||||
const trace = (result as any).data?.trace ?? args ?? {};
|
||||
const step = ensureNumber(trace.step ?? args?.step);
|
||||
const purpose = ensureString(trace.purpose ?? args?.purpose) || 'planning';
|
||||
const thoughts = ensureString(trace.thoughts ?? args?.thoughts);
|
||||
const next = ensureString(trace.next_action ?? args?.next_action);
|
||||
|
||||
let summary = `Plan${step ? ` step ${step}` : ''}: ${truncateOrDefault(purpose, 120, purpose)}`;
|
||||
if (thoughts) {
|
||||
summary += ` — ${truncateOrDefault(thoughts, 160, thoughts)}`;
|
||||
}
|
||||
if (next) {
|
||||
summary += `. Next: ${truncateOrDefault(next, 80, next)}`;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (toolName === 'webSearch') {
|
||||
const query = ensureString(args?.query) || ensureString(result.data?.query);
|
||||
const results = Array.isArray(result.data?.results) ? result.data.results : [];
|
||||
if (results.length > 0) {
|
||||
const items = results.slice(0, 3).map((entry: any) => {
|
||||
const title = ensureString(entry.title) || ensureString(entry.url) || 'Result';
|
||||
const url = ensureString(entry.url);
|
||||
return url ? `${truncateOrDefault(title, 80, title)} (${url})` : truncateOrDefault(title, 80, title);
|
||||
});
|
||||
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: ${items.join('; ')}`;
|
||||
}
|
||||
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no results.`;
|
||||
}
|
||||
|
||||
if (toolName === 'searchContentEmbeddings') {
|
||||
const query = ensureString(args?.query) || ensureString(result.data?.query);
|
||||
const chunks = Array.isArray(result.data?.chunks) ? result.data.chunks : [];
|
||||
if (chunks.length > 0) {
|
||||
const top = chunks[0];
|
||||
const snippet = ensureString(top.text);
|
||||
const nodeId = ensureNumber(top.node_id);
|
||||
const preview = truncateOrDefault(snippet, 160, snippet);
|
||||
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''} found ${chunks.length} chunk(s). Top${nodeId ? ` [NODE:${nodeId}]` : ''}: ${preview}`;
|
||||
}
|
||||
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no matches.`;
|
||||
}
|
||||
|
||||
if (toolName === 'youtubeExtract') {
|
||||
const title = ensureString(result.data?.title) || ensureString(args?.title);
|
||||
const formatted = ensureString(result.data?.formatted_display);
|
||||
if (formatted) {
|
||||
return `YouTube extract created ${formatted}.`;
|
||||
}
|
||||
if (title) {
|
||||
return `YouTube extract processed "${truncateOrDefault(title, 80, title)}".`;
|
||||
}
|
||||
return 'YouTube extract completed.';
|
||||
}
|
||||
|
||||
if (toolName === 'queryNodes') {
|
||||
const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : [];
|
||||
if (nodes.length > 0) {
|
||||
const labels = nodes
|
||||
.slice(0, 3)
|
||||
.map((node: any) => ensureString(node.formatted_display) || ensureString(node.title) || `[NODE:${node.id}]`)
|
||||
.join(', ');
|
||||
return `Found ${nodes.length} node(s): ${labels}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'queryEdge') {
|
||||
const edges = Array.isArray(result.data?.edges) ? result.data.edges : [];
|
||||
if (edges.length > 0) {
|
||||
const edge = edges[0];
|
||||
return `Found ${edges.length} edge(s), e.g., ${edge.from_node_id} → ${edge.to_node_id}.`;
|
||||
}
|
||||
return 'No edges found.';
|
||||
}
|
||||
|
||||
if (result.data?.formatted_display) {
|
||||
return ensureString(result.data.formatted_display) || fallback;
|
||||
}
|
||||
|
||||
if (result.data?.title) {
|
||||
return `Processed "${truncateOrDefault(ensureString(result.data.title), 80, result.data.title)}".`;
|
||||
}
|
||||
|
||||
if (result.data?.count !== undefined) {
|
||||
const count = result.data.count;
|
||||
return `${toolName} returned ${count} item(s).`;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = JSON.stringify(result.data ?? result);
|
||||
return truncateOrDefault(preview, 200, fallback);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { generateText } from 'ai';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
|
||||
export interface TranscriptSummaryResult {
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
intent?: string;
|
||||
progress?: string;
|
||||
highlights?: string[];
|
||||
openQuestions?: string[];
|
||||
participants?: string[];
|
||||
stickingPoints?: string[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 15000;
|
||||
|
||||
function buildPrompt(snippet: string) {
|
||||
return `You will receive a raw conversation transcript copy/pasted from various chat apps. It may contain timestamps, "You said:", "ChatGPT said:", missing speaker labels, or UI noise like "Thought for 11s".
|
||||
|
||||
Return ONLY valid JSON with this exact shape (no markdown, no commentary):
|
||||
{
|
||||
"subject": "Short descriptive thread title",
|
||||
"summary": "2-3 sentence narrative describing what was discussed and why it matters",
|
||||
"intent": "What the human seemed to be trying to accomplish (1 sentence, second-person)",
|
||||
"progress": "Where the conversation actually moved forward (1-2 sentences, second-person)",
|
||||
"stickingPoints": ["Concise phrases describing where you got stuck or needed help"],
|
||||
"highlights": ["Key takeaway 1", "Key takeaway 2"],
|
||||
"openQuestions": ["Follow-up 1"],
|
||||
"participants": ["user", "assistant"]
|
||||
}
|
||||
|
||||
Guidelines:
|
||||
- Never mention the formatting noise; describe the substance.
|
||||
- Keep highlights concise bullet phrases (<=12 words).
|
||||
- Sticking points should be authentic blockers, not restatements of highlights.
|
||||
- Open questions are the clearest next steps; omit the array when nothing actionable exists.
|
||||
- Participants should be lowercase role labels (user, assistant, reviewer, etc.).
|
||||
- Prefer phrasing that sounds like you're talking directly to the user ("you were trying to...").
|
||||
|
||||
Transcript:
|
||||
${snippet}`;
|
||||
}
|
||||
|
||||
export async function summarizeTranscript(transcript: string): Promise<TranscriptSummaryResult> {
|
||||
const limited = transcript.length > MAX_TRANSCRIPT_CHARS
|
||||
? transcript.slice(0, MAX_TRANSCRIPT_CHARS)
|
||||
: transcript;
|
||||
|
||||
try {
|
||||
const response = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt: buildPrompt(limited),
|
||||
maxOutputTokens: 600,
|
||||
});
|
||||
|
||||
let content = response.text || '';
|
||||
content = content.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
return {
|
||||
subject: typeof parsed.subject === 'string' ? parsed.subject : undefined,
|
||||
summary: typeof parsed.summary === 'string' ? parsed.summary : undefined,
|
||||
intent: typeof parsed.intent === 'string' ? parsed.intent : undefined,
|
||||
progress: typeof parsed.progress === 'string' ? parsed.progress : undefined,
|
||||
highlights: Array.isArray(parsed.highlights)
|
||||
? parsed.highlights.filter((h: unknown) => typeof h === 'string' && h.trim().length > 0)
|
||||
: [],
|
||||
openQuestions: Array.isArray(parsed.openQuestions)
|
||||
? parsed.openQuestions.filter((q: unknown) => typeof q === 'string' && q.trim().length > 0)
|
||||
: [],
|
||||
participants: Array.isArray(parsed.participants)
|
||||
? parsed.participants.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||
: [],
|
||||
stickingPoints: Array.isArray(parsed.stickingPoints)
|
||||
? parsed.stickingPoints.filter((s: unknown) => typeof s === 'string' && s.trim().length > 0)
|
||||
: [],
|
||||
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[TranscriptSummarizer] Failed to summarize transcript, falling back', error);
|
||||
return {
|
||||
summary: '',
|
||||
intent: '',
|
||||
progress: '',
|
||||
highlights: [],
|
||||
openQuestions: [],
|
||||
participants: [],
|
||||
stickingPoints: [],
|
||||
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type AgentRole = 'orchestrator' | 'executor' | 'planner';
|
||||
|
||||
export interface AgentDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
model: string;
|
||||
role: AgentRole;
|
||||
systemPrompt: string;
|
||||
availableTools: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memory?: Record<string, unknown> | null;
|
||||
prompts?: Array<{ id: string; name: string; content: string }>;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
key: string;
|
||||
displayName: string;
|
||||
role: AgentRole;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import { streamText, CoreMessage } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
|
||||
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';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
import { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
import { isLocalMode } from '@/config/runtime';
|
||||
|
||||
export interface WiseRAHExecutionInput {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
}
|
||||
|
||||
export class WiseRAHExecutor {
|
||||
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WiseRAHExecutionInput) {
|
||||
console.log('🧙 [WiseRAHExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
|
||||
try {
|
||||
const requestContext = RequestContext.get();
|
||||
const wiseRahKey =
|
||||
requestContext.apiKeys?.openai ||
|
||||
process.env.RAH_WISE_RAH_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.');
|
||||
}
|
||||
|
||||
AgentDelegationService.markInProgress(sessionId);
|
||||
console.log('✅ [WiseRAHExecutor] Delegation marked in progress');
|
||||
|
||||
const normalizedTask = task.toLowerCase();
|
||||
const normalizedOutcome = (expectedOutcome || '').toLowerCase();
|
||||
const isWorkflow = Boolean(workflowKey) || normalizedTask.startsWith('execute workflow');
|
||||
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;
|
||||
|
||||
const promptSections = [
|
||||
`Task: ${task}`,
|
||||
context.length ? `Context:\n- ${context.join('\n- ')}` : 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);
|
||||
|
||||
const openaiProvider = createOpenAI({ apiKey: wiseRahKey });
|
||||
console.log('🔧 [WiseRAHExecutor] OpenAI provider created');
|
||||
|
||||
const plannerTools = getToolsForRole('planner');
|
||||
|
||||
// Remove delegateToMiniRAH for integrate workflow (wise-rah does updates directly)
|
||||
if (workflowKey === 'integrate' && plannerTools.delegateToMiniRAH) {
|
||||
delete plannerTools.delegateToMiniRAH;
|
||||
console.log('🚫 [WiseRAHExecutor] Removed delegateToMiniRAH for integrate workflow (direct updates only)');
|
||||
}
|
||||
|
||||
// For analysis-only tasks, also remove delegation
|
||||
if (analysisOnly && plannerTools.delegateToMiniRAH) {
|
||||
delete plannerTools.delegateToMiniRAH;
|
||||
}
|
||||
|
||||
console.log('🛠️ [WiseRAHExecutor] Planner tools retrieved:', Object.keys(plannerTools));
|
||||
|
||||
const toolsUsedInSession: string[] = [];
|
||||
const delegatedEdgeKeys = new Set<string>();
|
||||
|
||||
// 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(
|
||||
Object.entries(plannerTools).map(([name, tool]) => {
|
||||
const wrapped = {
|
||||
...tool,
|
||||
async execute(params: any, context: any) {
|
||||
if (!toolsUsedInSession.includes(name)) {
|
||||
toolsUsedInSession.push(name);
|
||||
}
|
||||
if (name === 'delegateToMiniRAH') {
|
||||
const extractEdgeKey = () => {
|
||||
if (!params) return null;
|
||||
const tryFromTask = () => {
|
||||
if (typeof params.task !== 'string') return null;
|
||||
const matches = [...params.task.matchAll(/\[NODE:(\d+)/g)];
|
||||
if (matches.length >= 2) {
|
||||
const fromId = Number(matches[0][1]);
|
||||
const toId = Number(matches[1][1]);
|
||||
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryFromContext = () => {
|
||||
if (!Array.isArray(params.context)) return null;
|
||||
let fromId: number | null = null;
|
||||
let toId: number | null = null;
|
||||
for (const entry of params.context) {
|
||||
if (typeof entry === 'string') {
|
||||
const fromMatch = entry.match(/from_node_id\D+(\d+)/i);
|
||||
const toMatch = entry.match(/to_node_id\D+(\d+)/i);
|
||||
if (fromMatch && Number.isFinite(Number(fromMatch[1]))) {
|
||||
fromId = Number(fromMatch[1]);
|
||||
}
|
||||
if (toMatch && Number.isFinite(Number(toMatch[1]))) {
|
||||
toId = Number(toMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(fromId as number) && Number.isFinite(toId as number)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return tryFromTask() || tryFromContext();
|
||||
};
|
||||
|
||||
const edgeKey = extractEdgeKey();
|
||||
if (edgeKey) {
|
||||
if (delegatedEdgeKeys.has(edgeKey)) {
|
||||
const [from, to] = edgeKey.split('->');
|
||||
const message = `Skipped duplicate edge delegation for nodes ${from}→${to}.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
delegatedEdgeKeys.add(edgeKey);
|
||||
const [from, to] = edgeKey.split('->').map(Number);
|
||||
if (Number.isFinite(from) && Number.isFinite(to)) {
|
||||
const exists = await edgeService.edgeExists(from, to);
|
||||
if (exists) {
|
||||
const message = `Edge ${from}→${to} already exists; delegation skipped.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await tool.execute(params, context);
|
||||
}
|
||||
};
|
||||
return [name, wrapped];
|
||||
})
|
||||
);
|
||||
|
||||
// Enforce read-only constraint - remove write tools EXCEPT for workflows
|
||||
// Workflows (like integrate) need updateNode for direct content updates
|
||||
const writeToolsToRemove = isWorkflow
|
||||
? ['createNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
|
||||
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
|
||||
|
||||
writeToolsToRemove.forEach(toolName => {
|
||||
if (toolName in wrappedTools) {
|
||||
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');
|
||||
}
|
||||
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') }
|
||||
];
|
||||
|
||||
let finalText = '';
|
||||
const totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const maxIterations = maxIterationsLimit;
|
||||
|
||||
const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: 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 sanitizeForBroadcast = (value: unknown) => {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn('[WiseRAHExecutor] Failed to serialize delegation payload', error);
|
||||
if (typeof value === 'string') return value;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const emitDelegationEvent = (payload: Record<string, unknown>) => {
|
||||
delegationStreamBroadcaster.broadcast(sessionId, payload);
|
||||
};
|
||||
|
||||
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
|
||||
emitDelegationEvent({
|
||||
type: 'tool-input-start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
input: sanitizeForBroadcast(input),
|
||||
});
|
||||
};
|
||||
|
||||
const emitToolCompletion = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
rawResult: unknown,
|
||||
summary: string,
|
||||
status: 'complete' | 'error' = 'complete',
|
||||
errorMessage?: string
|
||||
) => {
|
||||
emitDelegationEvent({
|
||||
type: 'tool-output-available',
|
||||
toolCallId,
|
||||
toolName,
|
||||
result: sanitizeForBroadcast(rawResult),
|
||||
summary,
|
||||
status,
|
||||
error: errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const buildToolOutput = (toolName: string, summary: string, rawResult: any): LanguageModelV2ToolResultOutput => {
|
||||
const trimmedSummary = summary.trim();
|
||||
|
||||
if (rawResult && typeof rawResult === 'object' && rawResult.success === false) {
|
||||
const message = trimmedSummary || ensureString(rawResult.error) || `${toolName} failed.`;
|
||||
return { type: 'error-text', value: message };
|
||||
}
|
||||
|
||||
if (typeof rawResult === 'string') {
|
||||
const value = rawResult.trim() || trimmedSummary || `${toolName} completed.`;
|
||||
return { type: 'text', value };
|
||||
}
|
||||
|
||||
if (trimmedSummary) {
|
||||
return { type: 'text', value: trimmedSummary };
|
||||
}
|
||||
|
||||
return { type: 'text', value: `${toolName} completed.` };
|
||||
};
|
||||
|
||||
const requestFinalSummary = async (instruction: string) => {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: instruction,
|
||||
});
|
||||
|
||||
const finalStreamResult = await streamText({
|
||||
model: openaiProvider('gpt-5'),
|
||||
messages,
|
||||
tools: {},
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const finalChunks: string[] = [];
|
||||
for await (const chunk of finalStreamResult.textStream) {
|
||||
finalChunks.push(chunk);
|
||||
}
|
||||
|
||||
const finalResponse = {
|
||||
text: finalChunks.join(''),
|
||||
usage: await finalStreamResult.usage,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += finalResponse.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += finalResponse.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += finalResponse.usage?.totalTokens || 0;
|
||||
|
||||
return finalResponse.text ?? '';
|
||||
};
|
||||
|
||||
const normaliseForSignature = (toolName: string, input: any) => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (toolName === 'webSearch' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
if (toolName === 'searchContentEmbeddings' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
console.log(`🔄 [WiseRAHExecutor] Iteration ${i + 1}/${maxIterations}`);
|
||||
|
||||
// Touch delegation every iteration to prevent cleanup from killing it
|
||||
AgentDelegationService.touchDelegation(sessionId);
|
||||
|
||||
const streamResult = await streamText({
|
||||
model: openaiProvider('gpt-5'),
|
||||
messages,
|
||||
tools: wrappedTools,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of streamResult.textStream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const response = {
|
||||
text: chunks.join(''),
|
||||
finishReason: await streamResult.finishReason,
|
||||
usage: await streamResult.usage,
|
||||
toolCalls: await streamResult.toolCalls,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += response.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += response.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += response.usage?.totalTokens || 0;
|
||||
|
||||
console.log(`📊 [WiseRAHExecutor] Step ${i + 1} finishReason:`, response.finishReason);
|
||||
|
||||
// Stream text response to delegation chat
|
||||
if (response.text && response.text.trim()) {
|
||||
emitDelegationEvent({
|
||||
type: 'text-delta',
|
||||
delta: response.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.finishReason !== 'tool-calls') {
|
||||
finalText = response.text;
|
||||
console.log('✅ [WiseRAHExecutor] Got final text');
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCalls = response.toolCalls || [];
|
||||
console.log(`🔧 [WiseRAHExecutor] Executing ${toolCalls.length} tool calls`);
|
||||
|
||||
// Broadcast new assistant message for next iteration
|
||||
if (toolCalls.length > 0) {
|
||||
emitDelegationEvent({ type: 'assistant-message' });
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 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: LanguageModelV2ToolResultOutput;
|
||||
}> = [];
|
||||
|
||||
let executedTool = false;
|
||||
|
||||
for (const call of toolCalls) {
|
||||
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 signature = JSON.stringify({ tool: call.toolName, input: signatureInput });
|
||||
|
||||
// Broadcast tool call to delegation stream
|
||||
emitToolStart(call.toolCallId, call.toolName, callInputRaw);
|
||||
|
||||
if (!hasPlan && call.toolName !== 'think') {
|
||||
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)) {
|
||||
const cached = seenToolResults.get(signature)!;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: cached.output,
|
||||
});
|
||||
|
||||
// Broadcast 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;
|
||||
}
|
||||
|
||||
const tool = wrappedTools[call.toolName];
|
||||
if (!tool) {
|
||||
const warning = `Tool ${call.toolName} is not available to wise ra-h.`;
|
||||
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);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawResult = await tool.execute(callInputRaw, {});
|
||||
const summary = summarizeToolExecution(call.toolName, callInputRaw, rawResult);
|
||||
const output = buildToolOutput(call.toolName, summary, rawResult);
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output,
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete');
|
||||
|
||||
if (call.toolName === 'think') {
|
||||
hasPlan = true;
|
||||
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 });
|
||||
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) {
|
||||
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 },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, message, 'error', message);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
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 (!finalText) {
|
||||
if (allowWrites && totalDelegations === 0) {
|
||||
throw new Error('Wise ra-h attempted to summarize without delegating any execution to mini ra-h.');
|
||||
}
|
||||
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;
|
||||
let summary = typeof finalText === 'string' ? finalText.trim() : '';
|
||||
|
||||
if (summary.length > 2000) {
|
||||
console.log('⚠️ [WiseRAHExecutor] 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();
|
||||
}
|
||||
if (summary.length > 1000) {
|
||||
summary = `${summary.slice(0, 997)}…`;
|
||||
}
|
||||
console.log('📄 [WiseRAHExecutor] Summary after trim:', summary);
|
||||
console.log('📏 [WiseRAHExecutor] Summary length:', summary.length);
|
||||
|
||||
if (!summary) {
|
||||
emitDelegationEvent({
|
||||
type: 'assistant-message',
|
||||
});
|
||||
emitDelegationEvent({
|
||||
type: 'text-delta',
|
||||
delta: 'Wise ra-h attempted to summarise but the response was empty. Check tool logs above for context.',
|
||||
});
|
||||
throw new Error('Wise ra-h returned empty summary');
|
||||
}
|
||||
|
||||
console.log('[WiseRAHExecutor] summary:', summary);
|
||||
|
||||
// Calculate cost and log to chats table
|
||||
if (usage) {
|
||||
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
|
||||
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
const costResult = calculateCost({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
const usageData: UsageData = {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd: costResult.totalCostUsd,
|
||||
modelUsed: 'gpt-5',
|
||||
provider: 'openai',
|
||||
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
||||
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
};
|
||||
|
||||
const delegation = AgentDelegationService.getDelegation(sessionId);
|
||||
const delegationId = delegation?.id;
|
||||
|
||||
await ChatLoggingMiddleware.logChatInteraction(
|
||||
task,
|
||||
summary,
|
||||
{
|
||||
helperName: 'wise-rah',
|
||||
agentType: 'planner',
|
||||
delegationId: delegationId ?? null,
|
||||
sessionId,
|
||||
usageData,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
systemMessage: WISE_RAH_SYSTEM_PROMPT,
|
||||
backendUsage: [],
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
console.log(`💰 [WiseRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
|
||||
}
|
||||
|
||||
console.log('✅ [WiseRAHExecutor] Completing delegation with summary');
|
||||
return AgentDelegationService.completeDelegation(sessionId, summary);
|
||||
} catch (error) {
|
||||
console.error('❌ [WiseRAHExecutor] Error during execution:', error);
|
||||
console.error('❌ [WiseRAHExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||
const message = error instanceof Error ? error.message : 'Unknown delegation error';
|
||||
|
||||
// Broadcast error to delegation stream
|
||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
||||
type: 'assistant-message',
|
||||
});
|
||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
||||
type: 'text-delta',
|
||||
delta: `Wise ra-h failed: ${message}`,
|
||||
});
|
||||
|
||||
AgentDelegationService.completeDelegation(sessionId, `Wise ra-h failed: ${message}`, 'failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user