885 lines
35 KiB
TypeScript
885 lines
35 KiB
TypeScript
/**
|
|
* @Orchestrator agent — LangGraph-based intent routing.
|
|
*
|
|
* The agent logic (routing, state) lives here and is fully LLM-agnostic.
|
|
* The LLM is a swappable connector obtained via `getLLM()`.
|
|
*/
|
|
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
|
|
import { SystemMessage, HumanMessage, AIMessage, ToolMessage, type BaseMessage, type ToolCall } from '@langchain/core/messages';
|
|
import { tool, type StructuredTool } from '@langchain/core/tools';
|
|
import { eq, asc } from 'drizzle-orm';
|
|
import { z } from 'zod';
|
|
import { getDb } from '../db';
|
|
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
|
import { getLLM } from './llm';
|
|
import { getActiveProviderName } from './provider';
|
|
import { searchNotes, type SearchResult } from '../db/vectordb';
|
|
|
|
/**
|
|
* Providers with tool calling support.
|
|
* OpenAI/Anthropic: LangChain bindTools + ToolMessage agent loop.
|
|
* Copilot: ChatCopilot.bindTools() converts to SDK-native tools; the SDK handles
|
|
* the agentic loop internally so tool_calls is always [] on the response.
|
|
*/
|
|
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
|
|
|
|
const AI_STREAM_CHANNEL = 'ai:stream';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface OrchestrateInput {
|
|
message: string;
|
|
context: { type: 'global' | 'project'; projectId?: string };
|
|
sender?: Electron.WebContents;
|
|
}
|
|
|
|
export interface OrchestrateResult {
|
|
response: string;
|
|
error?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context assembly (DB queries — provider-independent)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildProjectContext(projectId: string): string {
|
|
const db = getDb();
|
|
|
|
const project = db.select().from(projects).where(eq(projects.id, projectId)).all()[0];
|
|
if (!project) return 'Project not found.';
|
|
|
|
let clientName = '';
|
|
if (project.clientId) {
|
|
const client = db.select().from(clients).where(eq(clients.id, project.clientId)).all()[0];
|
|
if (client) clientName = client.name;
|
|
}
|
|
|
|
const projectTasks = db
|
|
.select({ title: tasks.title, status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate })
|
|
.from(tasks)
|
|
.where(eq(tasks.projectId, projectId))
|
|
.orderBy(asc(tasks.createdAt))
|
|
.all();
|
|
|
|
const projectCheckpoints = db
|
|
.select({ title: checkpoints.title, date: checkpoints.date, isApproved: checkpoints.isApproved })
|
|
.from(checkpoints)
|
|
.where(eq(checkpoints.projectId, projectId))
|
|
.orderBy(asc(checkpoints.date))
|
|
.all();
|
|
|
|
const projectNotes = db
|
|
.select({ title: notes.title, content: notes.content })
|
|
.from(notes)
|
|
.where(eq(notes.projectId, projectId))
|
|
.orderBy(asc(notes.createdAt))
|
|
.all();
|
|
|
|
const lines: string[] = [
|
|
`## Project: ${project.name}`,
|
|
clientName ? `Client: ${clientName}` : '',
|
|
`Status: ${project.status ?? 'active'}`,
|
|
project.aiSummary ? `AI Summary: ${project.aiSummary}` : '',
|
|
'',
|
|
`### Tasks (${projectTasks.length})`,
|
|
...projectTasks.map((t) => {
|
|
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : 'no due date';
|
|
return `- [${t.status}] ${t.title} (${t.priority}, ${due})`;
|
|
}),
|
|
'',
|
|
`### Checkpoints (${projectCheckpoints.length})`,
|
|
...projectCheckpoints.map((c) => {
|
|
const approved = c.isApproved ? 'approved' : 'pending';
|
|
return `- ${c.title} — ${new Date(c.date).toLocaleDateString()} (${approved})`;
|
|
}),
|
|
'',
|
|
`### Notes (${projectNotes.length})`,
|
|
...projectNotes.map((n) => {
|
|
const excerpt = n.content.length > 500 ? n.content.slice(0, 500) + '…' : n.content;
|
|
return `#### ${n.title}\n${excerpt}`;
|
|
}),
|
|
];
|
|
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|
|
|
|
function buildGlobalContext(): string {
|
|
const db = getDb();
|
|
|
|
const allProjects = db
|
|
.select({ id: projects.id, name: projects.name, status: projects.status })
|
|
.from(projects)
|
|
.where(eq(projects.status, 'active'))
|
|
.orderBy(asc(projects.name))
|
|
.all();
|
|
|
|
const allTasks = db.select().from(tasks).all();
|
|
const todoCount = allTasks.filter((t) => t.status === 'todo').length;
|
|
const inProgressCount = allTasks.filter((t) => t.status === 'in_progress').length;
|
|
const doneCount = allTasks.filter((t) => t.status === 'done').length;
|
|
|
|
const now = Date.now();
|
|
const weekFromNow = now + 7 * 24 * 60 * 60 * 1000;
|
|
const upcomingTasks = allTasks
|
|
.filter((t) => t.dueDate && t.dueDate >= now && t.dueDate <= weekFromNow && t.status !== 'done')
|
|
.sort((a, b) => (a.dueDate ?? 0) - (b.dueDate ?? 0));
|
|
|
|
const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
|
|
|
|
const lines: string[] = [
|
|
`## Workspace Overview`,
|
|
`Active projects: ${allProjects.length}`,
|
|
`Tasks: ${allTasks.length} total (${todoCount} todo, ${inProgressCount} in progress, ${doneCount} done)`,
|
|
'',
|
|
`### Active Projects`,
|
|
...allProjects.map((p) => `- ${p.name}`),
|
|
'',
|
|
`### Tasks Due This Week (${upcomingTasks.length})`,
|
|
...upcomingTasks.map((t) => {
|
|
const projectName = t.projectId ? (projectMap.get(t.projectId) ?? 'Unknown') : 'No project';
|
|
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '';
|
|
return `- ${t.title} [${projectName}] — due ${due}`;
|
|
}),
|
|
];
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project action tools (built per-invocation, scoped to a project)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildProjectTools(projectId: string): StructuredTool[] {
|
|
const db = getDb();
|
|
|
|
const readProjectNotesTool = tool(
|
|
async (_input: Record<string, never>) => {
|
|
const projectNotes = db
|
|
.select({ title: notes.title, content: notes.content })
|
|
.from(notes)
|
|
.where(eq(notes.projectId, projectId))
|
|
.orderBy(asc(notes.createdAt))
|
|
.all();
|
|
if (projectNotes.length === 0) return 'No notes found for this project.';
|
|
return projectNotes.map((n) => `### ${n.title}\n${n.content}`).join('\n\n---\n\n');
|
|
},
|
|
{
|
|
name: 'read_project_notes',
|
|
description:
|
|
'Fetches the full content of all notes for this project from the database. Use this when the user asks a detailed question about project notes or decisions.',
|
|
schema: z.object({}),
|
|
},
|
|
);
|
|
|
|
const addTaskTool = tool(
|
|
async (input: { title: string; description?: string; priority?: string; dueDate?: string }) => {
|
|
const id = crypto.randomUUID();
|
|
db.insert(tasks)
|
|
.values({
|
|
id,
|
|
title: input.title,
|
|
description: input.description ?? null,
|
|
status: 'todo',
|
|
priority: input.priority ?? 'medium',
|
|
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
|
projectId,
|
|
createdAt: Date.now(),
|
|
})
|
|
.run();
|
|
return `Task added: ${input.title}`;
|
|
},
|
|
{
|
|
name: 'add_task',
|
|
description:
|
|
"Creates a new task in this project. Use when the user asks to add, create, or log a task. Returns 'Task added: [title]' on success.",
|
|
schema: z.object({
|
|
title: z.string().describe('Task title (required)'),
|
|
description: z.string().optional().describe('Optional longer description'),
|
|
priority: z.enum(['low', 'medium', 'high']).optional().describe('Task priority, defaults to medium'),
|
|
dueDate: z.string().optional().describe('ISO 8601 date or datetime string — include the time when specified, e.g. 2026-03-15 or 2026-03-15T17:00:00'),
|
|
}),
|
|
},
|
|
);
|
|
|
|
const getSummaryTool = tool(
|
|
async (_input: Record<string, never>) => {
|
|
const contextData = buildProjectContext(projectId);
|
|
const summaryLlm = await getLLM();
|
|
if (!summaryLlm) return 'Error: AI provider not available to generate summary.';
|
|
const result = await summaryLlm.invoke([
|
|
new SystemMessage(
|
|
'Generate a concise 2-3 sentence project summary based on the data below. ' +
|
|
'Focus on current status, key tasks, and notable milestones. Be direct and factual.',
|
|
),
|
|
new HumanMessage(contextData),
|
|
]);
|
|
const summary = typeof result.content === 'string' ? result.content : '';
|
|
db.update(projects).set({ aiSummary: summary }).where(eq(projects.id, projectId)).run();
|
|
return summary;
|
|
},
|
|
{
|
|
name: 'get_summary',
|
|
description:
|
|
'Generates a 2-3 sentence AI summary of the project based on its notes and tasks, ' +
|
|
'then saves it to the project record (project.aiSummary). Returns the summary text.',
|
|
schema: z.object({}),
|
|
},
|
|
);
|
|
|
|
const suggestCheckpointsTool = tool(
|
|
async (_input: Record<string, never>) => {
|
|
const contextData = buildProjectContext(projectId);
|
|
const suggestionLlm = await getLLM();
|
|
if (!suggestionLlm) return '[]';
|
|
const result = await suggestionLlm.invoke([
|
|
new SystemMessage(
|
|
'You are a project planning assistant. Analyze the project data below and extract ' +
|
|
'date-anchored commitments from the notes (e.g. "deliver X by March 15", "review on April 2").\n\n' +
|
|
'Return ONLY a valid JSON array of objects with shape { "title": string, "date": string } ' +
|
|
'where date is ISO 8601 format (YYYY-MM-DD). Return [] if no date-anchored commitments are found.\n\n' +
|
|
'Example: [{"title":"Client review","date":"2026-03-15"},{"title":"Beta launch","date":"2026-04-01"}]',
|
|
),
|
|
new HumanMessage(contextData),
|
|
]);
|
|
const content = typeof result.content === 'string' ? result.content.trim() : '[]';
|
|
// Extract JSON array from response (model may wrap in markdown)
|
|
const match = content.match(/\[[\s\S]*\]/);
|
|
const jsonStr = match ? match[0] : '[]';
|
|
try {
|
|
JSON.parse(jsonStr);
|
|
return jsonStr;
|
|
} catch {
|
|
return '[]';
|
|
}
|
|
},
|
|
{
|
|
name: 'suggest_checkpoints',
|
|
description:
|
|
'Analyzes project notes for date-anchored commitments and returns a JSON array of ' +
|
|
'{ title: string, date: string } suggested checkpoints. Use when the user asks for timeline or milestone suggestions.',
|
|
schema: z.object({}),
|
|
},
|
|
);
|
|
|
|
return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool] as StructuredTool[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global action tools (workspace-level, no project scope required)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildGlobalTools(): StructuredTool[] {
|
|
const db = getDb();
|
|
|
|
const addTaskTool = tool(
|
|
async (input: { title: string; description?: string; priority?: string; dueDate?: string; projectId?: string }) => {
|
|
const id = crypto.randomUUID();
|
|
db.insert(tasks)
|
|
.values({
|
|
id,
|
|
title: input.title,
|
|
description: input.description ?? null,
|
|
status: 'todo',
|
|
priority: input.priority ?? 'medium',
|
|
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
|
projectId: input.projectId ?? null,
|
|
createdAt: Date.now(),
|
|
})
|
|
.run();
|
|
return `Task added: ${input.title}`;
|
|
},
|
|
{
|
|
name: 'add_task',
|
|
description:
|
|
"Creates a new task in the workspace. Use when the user asks to add, create, or register a task. " +
|
|
"Returns 'Task added: [title]' on success.",
|
|
schema: z.object({
|
|
title: z.string().describe('Task title (required)'),
|
|
description: z.string().optional().describe('Optional longer description'),
|
|
priority: z.enum(['low', 'medium', 'high']).optional().describe('Task priority, defaults to medium'),
|
|
dueDate: z.string().optional().describe('ISO 8601 date or datetime string — include the time when specified, e.g. 2026-03-15 or 2026-03-15T17:00:00'),
|
|
projectId: z.string().optional().describe('Optional project ID to associate the task with'),
|
|
}),
|
|
},
|
|
);
|
|
|
|
return [addTaskTool] as StructuredTool[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Knowledge tools (cross-project vector search)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildKnowledgeTools(): StructuredTool[] {
|
|
const db = getDb();
|
|
|
|
const vectorSearchAllTool = tool(
|
|
async (input: { query: string }) => {
|
|
const results: SearchResult[] = await searchNotes(input.query, 5);
|
|
|
|
if (results.length === 0) {
|
|
return 'No matching notes found across projects.';
|
|
}
|
|
|
|
const enriched = results.map((r) => {
|
|
const noteRow = db
|
|
.select({ title: notes.title })
|
|
.from(notes)
|
|
.where(eq(notes.id, r.id))
|
|
.all()[0];
|
|
|
|
let projectName = 'No project';
|
|
if (r.projectId) {
|
|
const projectRow = db
|
|
.select({ name: projects.name })
|
|
.from(projects)
|
|
.where(eq(projects.id, r.projectId))
|
|
.all()[0];
|
|
if (projectRow) projectName = projectRow.name;
|
|
}
|
|
|
|
const title = noteRow?.title ?? 'Untitled';
|
|
const excerpt =
|
|
r.content.length > 300 ? r.content.slice(0, 300) + '…' : r.content;
|
|
|
|
return [
|
|
`**From: ${projectName} — ${title}**`,
|
|
`Note ID: ${r.id} | Project ID: ${r.projectId}`,
|
|
excerpt,
|
|
].join('\n');
|
|
});
|
|
|
|
return enriched.join('\n\n---\n\n');
|
|
},
|
|
{
|
|
name: 'vector_search_all',
|
|
description:
|
|
'Performs a semantic search across ALL project notes in the workspace. ' +
|
|
'Returns the top 5 most relevant notes with their project name, note title, and a text excerpt. ' +
|
|
'Use this tool whenever the user asks a cross-project knowledge question.',
|
|
schema: z.object({
|
|
query: z.string().describe('The search query to find relevant notes across all projects'),
|
|
}),
|
|
},
|
|
);
|
|
|
|
return [vectorSearchAllTool] as StructuredTool[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// System prompts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeProjectAgentPrompt(contextData: string, withTools = true): string {
|
|
const toolsSection = withTools ? `
|
|
You also have access to the following tools — use them proactively when appropriate:
|
|
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
|
- add_task: Create a new task in this project. Use when the user asks to add a task.
|
|
- get_summary: Generate and save a 2-3 sentence project summary. Use when asked to summarize.
|
|
- suggest_checkpoints: Extract date-based milestone suggestions from notes. Use for timeline/checkpoint questions.
|
|
|
|
When suggest_checkpoints returns a JSON array, present the checkpoints in a readable bullet-point format.` : '';
|
|
|
|
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
|
|
|
You have access to the following project data:
|
|
|
|
${contextData}
|
|
${toolsSection}
|
|
Answer the user's question based on this project context. Be concise and helpful.
|
|
When referencing tasks, notes, or checkpoints, mention them by name.
|
|
If you don't have enough information, say so.`;
|
|
}
|
|
|
|
function makeGeneralAgentPrompt(contextData: string, withTools = true): string {
|
|
const toolsSection = withTools ? `
|
|
You also have access to the following tools — use them proactively when appropriate:
|
|
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
|
|
|
When creating a task from a user request, infer a clear title and set dueDate if a specific date/time is mentioned.` : '';
|
|
|
|
return `You are @GeneralAgent, an AI assistant for the Adiuva workspace.
|
|
|
|
You have access to the following workspace data:
|
|
|
|
${contextData}
|
|
${toolsSection}
|
|
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
|
When discussing tasks or projects, reference them by name.`;
|
|
}
|
|
|
|
function makeKnowledgeAgentPrompt(contextData: string, withTools = true): string {
|
|
const toolsSection = withTools ? `
|
|
You have access to the following tools — use them proactively:
|
|
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
|
|
|
IMPORTANT: After receiving search results, format your response with inline citations.
|
|
For each piece of information you reference, include the citation in this exact format:
|
|
From: [Project Name] — [Note Title]
|
|
|
|
Example:
|
|
"The team decided to use React for the frontend. (From: Website Redesign — Tech Stack Decision)"` : '';
|
|
|
|
return `You are @KnowledgeAgent, an AI assistant that searches across all project knowledge in Adiuva.
|
|
|
|
You have access to the following workspace data:
|
|
|
|
${contextData}
|
|
${toolsSection}
|
|
Your primary job is to find and synthesize information from notes across all projects.
|
|
Always use the vector_search_all tool to search for relevant notes before answering.
|
|
If no results are found, say so clearly.`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LangGraph State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OrchestratorState = Annotation.Root({
|
|
/** The user's original message */
|
|
userMessage: Annotation<string>(),
|
|
/** Chat context (global vs project-scoped) */
|
|
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string }>(),
|
|
/** The route chosen by the orchestrator */
|
|
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
|
/** Messages for the specialist agent */
|
|
messages: Annotation<BaseMessage[]>({
|
|
reducer: (existing, incoming) =>
|
|
Array.isArray(incoming) ? existing.concat(incoming) : existing.concat([incoming]),
|
|
default: () => [],
|
|
}),
|
|
/** The final response text */
|
|
response: Annotation<string>(),
|
|
});
|
|
|
|
type State = typeof OrchestratorState.State;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Graph nodes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Node 1: Classify intent — always calls the LLM for every provider */
|
|
async function classifyIntent(state: State): Promise<Partial<State>> {
|
|
const llm = await getLLM();
|
|
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
|
|
|
|
const response = await llm.invoke([
|
|
new SystemMessage(
|
|
`You are a routing classifier for Adiuva, a project management workspace.
|
|
Classify the user's message into exactly one category. Reply with ONLY the category name, nothing else.
|
|
|
|
Categories:
|
|
- project: Question about a specific project (tasks, notes, checkpoints, progress, summaries)
|
|
- knowledge: Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
|
|
- general: Everything else (general help, scheduling, task overviews, workspace summaries)`,
|
|
),
|
|
new HumanMessage(state.userMessage),
|
|
]);
|
|
|
|
const text = (typeof response.content === 'string' ? response.content : '').trim().toLowerCase();
|
|
const validRoutes = ['project', 'knowledge', 'general'] as const;
|
|
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
|
|
|
|
console.log(`[Orchestrator] classifyIntent → route="${route}" (raw="${text}")`);
|
|
return { route };
|
|
}
|
|
|
|
/** Node 2a: Project agent — tools-enabled agentic loop for project-scoped questions */
|
|
async function projectAgent(state: State): Promise<Partial<State>> {
|
|
const llm = await getLLM();
|
|
if (!llm) throw new Error('AI provider not configured.');
|
|
|
|
const projectId = state.chatContext.projectId;
|
|
|
|
// If no projectId in context, delegate to generalAgent
|
|
if (!projectId) {
|
|
return generalAgent(state);
|
|
}
|
|
|
|
const contextData = buildProjectContext(projectId);
|
|
|
|
// Only providers with real tool calling support use the agent loop.
|
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
|
|
|
console.log(`[Orchestrator] projectAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}, projectId=${projectId}`);
|
|
|
|
// Copilot tools are registered natively via the SDK (createSession({ tools })).
|
|
// Including text tool descriptions in the system prompt causes the model to output
|
|
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
|
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt);
|
|
|
|
if (!supportsTools) {
|
|
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
|
// Providers without any tool calling support: answer from context data only
|
|
const response = await llm.invoke([
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
]);
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: [response], response: content };
|
|
}
|
|
|
|
// Build tools scoped to this projectId
|
|
const projectTools = buildProjectTools(projectId);
|
|
|
|
console.log(`[Orchestrator] projectAgent: binding ${projectTools.length} tools: [${projectTools.map((t) => t.name).join(', ')}]`);
|
|
|
|
// Bind tools: OpenAI/Anthropic use LangChain's bindTools (ToolMessage loop);
|
|
// Copilot uses ChatCopilot.bindTools() which registers tools with the SDK natively.
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const llmWithTools = llm.bindTools!(projectTools);
|
|
|
|
// Agent loop: invoke LLM → execute tool calls → repeat until no tool_calls
|
|
const MAX_ITERATIONS = 5;
|
|
const messageHistory: BaseMessage[] = [
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
];
|
|
|
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
const response = await llmWithTools.invoke(messageHistory);
|
|
messageHistory.push(response);
|
|
|
|
// Extract tool calls using type guard (no unsafe cast needed)
|
|
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
|
|
|
console.log(`[Orchestrator] agent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
|
|
|
// No tool calls → LLM produced a final text response
|
|
if (toolCalls.length === 0) {
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: messageHistory, response: content };
|
|
}
|
|
|
|
// Execute each tool call and append ToolMessages to history
|
|
for (const toolCall of toolCalls) {
|
|
const matched = projectTools.find((t) => t.name === toolCall.name);
|
|
if (!matched) {
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: `Error: tool "${toolCall.name}" is not available.`,
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Invoke with the ToolCall object — StructuredTool.invoke() detects _isToolCall()
|
|
// and extracts args internally (same pattern used by LangGraph's own ToolNode).
|
|
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
|
const resultContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: resultContent,
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Exceeded max iterations: extract last AI response text as best-effort fallback
|
|
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
|
const fallbackContent =
|
|
lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
|
return { messages: messageHistory, response: fallbackContent };
|
|
}
|
|
|
|
/** Node 2b: Knowledge agent — cross-project semantic search */
|
|
async function knowledgeAgent(state: State): Promise<Partial<State>> {
|
|
const llm = await getLLM();
|
|
if (!llm) throw new Error('AI provider not configured.');
|
|
|
|
const contextData = buildGlobalContext();
|
|
|
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
|
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt);
|
|
|
|
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
|
|
|
if (!supportsTools) {
|
|
const response = await llm.invoke([
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
]);
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: [response], response: content };
|
|
}
|
|
|
|
const knowledgeTools = buildKnowledgeTools();
|
|
|
|
console.log(`[Orchestrator] knowledgeAgent: binding ${knowledgeTools.length} tools: [${knowledgeTools.map((t) => t.name).join(', ')}]`);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const llmWithTools = llm.bindTools!(knowledgeTools);
|
|
|
|
const MAX_ITERATIONS = 5;
|
|
const messageHistory: BaseMessage[] = [
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
];
|
|
|
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
const response = await llmWithTools.invoke(messageHistory);
|
|
messageHistory.push(response);
|
|
|
|
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
|
|
|
console.log(`[Orchestrator] knowledgeAgent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
|
|
|
if (toolCalls.length === 0) {
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: messageHistory, response: content };
|
|
}
|
|
|
|
for (const toolCall of toolCalls) {
|
|
const matched = knowledgeTools.find((t) => t.name === toolCall.name);
|
|
if (!matched) {
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: `Error: tool "${toolCall.name}" is not available.`,
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
|
const resultContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: resultContent,
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
|
const fallbackContent =
|
|
lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
|
return { messages: messageHistory, response: fallbackContent };
|
|
}
|
|
|
|
/** Node 2c: General agent — workspace-wide questions and global task actions */
|
|
async function generalAgent(state: State): Promise<Partial<State>> {
|
|
const llm = await getLLM();
|
|
if (!llm) throw new Error('AI provider not configured.');
|
|
|
|
const contextData = buildGlobalContext();
|
|
|
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
|
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt);
|
|
|
|
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
|
|
|
if (!supportsTools) {
|
|
const response = await llm.invoke([
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
]);
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: [response], response: content };
|
|
}
|
|
|
|
const globalTools = buildGlobalTools();
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const llmWithTools = llm.bindTools!(globalTools);
|
|
|
|
const MAX_ITERATIONS = 5;
|
|
const messageHistory: BaseMessage[] = [
|
|
new SystemMessage(systemPrompt),
|
|
new HumanMessage(state.userMessage),
|
|
];
|
|
|
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
const response = await llmWithTools.invoke(messageHistory);
|
|
messageHistory.push(response);
|
|
|
|
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
|
|
|
console.log(`[Orchestrator] generalAgent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
|
|
|
if (toolCalls.length === 0) {
|
|
const content = typeof response.content === 'string' ? response.content : '';
|
|
return { messages: messageHistory, response: content };
|
|
}
|
|
|
|
for (const toolCall of toolCalls) {
|
|
const matched = globalTools.find((t) => t.name === toolCall.name);
|
|
if (!matched) {
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: `Error: tool "${toolCall.name}" is not available.`,
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
|
messageHistory.push(
|
|
new ToolMessage({
|
|
content: typeof output === 'string' ? output : JSON.stringify(output),
|
|
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
|
const fallbackContent = lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
|
return { messages: messageHistory, response: fallbackContent };
|
|
}
|
|
|
|
/** Routing function: reads state.route and returns the next node name */
|
|
function routeDecision(state: State): string {
|
|
switch (state.route) {
|
|
case 'project': return 'projectAgent';
|
|
case 'knowledge': return 'knowledgeAgent';
|
|
case 'general': return 'generalAgent';
|
|
default: return 'generalAgent';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compile the graph (singleton, reused across calls)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildGraph() {
|
|
return new StateGraph(OrchestratorState)
|
|
.addNode('classifyIntent', classifyIntent)
|
|
.addNode('projectAgent', projectAgent)
|
|
.addNode('knowledgeAgent', knowledgeAgent)
|
|
.addNode('generalAgent', generalAgent)
|
|
.addEdge(START, 'classifyIntent')
|
|
.addConditionalEdges('classifyIntent', routeDecision, [
|
|
'projectAgent', 'knowledgeAgent', 'generalAgent',
|
|
])
|
|
.addEdge('projectAgent', END)
|
|
.addEdge('knowledgeAgent', END)
|
|
.addEdge('generalAgent', END)
|
|
.compile();
|
|
}
|
|
|
|
let compiledGraph: ReturnType<typeof buildGraph> | null = null;
|
|
|
|
function getGraph() {
|
|
if (!compiledGraph) {
|
|
compiledGraph = buildGraph();
|
|
}
|
|
return compiledGraph;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Streaming helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sendStreamChunk(sender: Electron.WebContents | undefined, token: string, done: boolean): void {
|
|
if (!sender || sender.isDestroyed()) return;
|
|
sender.send(AI_STREAM_CHANNEL, { token, done });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestrate (public entry point)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
|
const { message, context, sender } = input;
|
|
|
|
// Quick check: is an LLM available?
|
|
const llm = await getLLM();
|
|
if (!llm) {
|
|
return { response: '', error: 'AI provider not configured. Please add your token in Settings.' };
|
|
}
|
|
|
|
try {
|
|
const graph = getGraph();
|
|
|
|
// Use streaming to push tokens to the renderer in real-time
|
|
const stream = await graph.stream(
|
|
{
|
|
userMessage: message,
|
|
chatContext: context,
|
|
route: 'general' as const,
|
|
response: '',
|
|
},
|
|
{ streamMode: 'messages' as const },
|
|
);
|
|
|
|
let fullResponse = '';
|
|
|
|
// Filter state: suppress <tool_call>...</tool_call> blocks that the SDK model emits
|
|
// as raw text when it attempts to use its built-in tools (sql, bash, etc.) in a
|
|
// non-tool session. We only want plain-text responses.
|
|
let inToolCallBlock = false;
|
|
let toolCallBuffer = '';
|
|
|
|
for await (const [chunk, metadata] of stream) {
|
|
// Only stream tokens from the specialist agent nodes (not the classifier),
|
|
// and only AI message chunks (not system/human/tool messages that are also
|
|
// emitted by LangGraph when messageHistory is returned in the state update).
|
|
if (
|
|
metadata.langgraph_node !== 'classifyIntent' &&
|
|
chunk.content &&
|
|
typeof chunk.content === 'string' &&
|
|
chunk._getType() === 'ai'
|
|
) {
|
|
const text = chunk.content as string;
|
|
fullResponse += text;
|
|
|
|
if (inToolCallBlock) {
|
|
toolCallBuffer += text;
|
|
if (toolCallBuffer.includes('</tool_call>')) {
|
|
const after = toolCallBuffer.split('</tool_call>').slice(1).join('</tool_call>');
|
|
inToolCallBlock = false;
|
|
toolCallBuffer = '';
|
|
const afterClean = after.replace(/^\n/, '');
|
|
if (afterClean) sendStreamChunk(sender, afterClean, false);
|
|
}
|
|
} else if (text.includes('<tool_call>')) {
|
|
const before = text.split('<tool_call>')[0];
|
|
if (before) sendStreamChunk(sender, before, false);
|
|
inToolCallBlock = true;
|
|
toolCallBuffer = '<tool_call>' + text.split('<tool_call>').slice(1).join('<tool_call>');
|
|
if (toolCallBuffer.includes('</tool_call>')) {
|
|
const after = toolCallBuffer.split('</tool_call>').slice(1).join('</tool_call>');
|
|
inToolCallBlock = false;
|
|
toolCallBuffer = '';
|
|
const afterClean = after.replace(/^\n/, '');
|
|
if (afterClean) sendStreamChunk(sender, afterClean, false);
|
|
}
|
|
} else {
|
|
sendStreamChunk(sender, text, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Signal stream completion
|
|
sendStreamChunk(sender, '', true);
|
|
|
|
return { response: fullResponse };
|
|
} catch (err) {
|
|
sendStreamChunk(sender, '', true);
|
|
|
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
|
|
if (errMsg.includes('401') || errMsg.includes('403') || errMsg.includes('auth') || errMsg.includes('Unauthorized')) {
|
|
return { response: '', error: 'Authentication failed. Please check your token in Settings.' };
|
|
}
|
|
if (errMsg.includes('timeout') || errMsg.includes('Timeout')) {
|
|
return { response: '', error: 'Request timed out. Please try again.' };
|
|
}
|
|
if (errMsg.toLowerCase().includes('list models') || errMsg.toLowerCase().includes('listmodels')) {
|
|
return { response: '', error: 'GitHub Copilot model service is unavailable. Please re-authenticate (copilot auth login) or switch to a different AI provider in Settings.' };
|
|
}
|
|
|
|
return { response: '', error: errMsg };
|
|
}
|
|
}
|