/** * @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'; const AI_ACTION_CHANNEL = 'ai:action'; /** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */ let currentSender: Electron.WebContents | undefined; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface OrchestrateInput { message: string; context: { type: 'global' | 'project'; projectId?: string; uiContext?: string }; sender?: Electron.WebContents; } 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) => { 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, isAiSuggested: 1, createdAt: Date.now(), }) .run(); sendAction(currentSender, { type: 'task_created', taskId: id }); 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) => { 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) => { 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 { const parsed = JSON.parse(jsonStr) as Array<{ title: string; date: string }>; for (const s of parsed) { const ts = new Date(s.date).getTime(); if (Number.isNaN(ts)) continue; db.insert(checkpoints).values({ id: crypto.randomUUID(), projectId, title: s.title, date: ts, isAiSuggested: 1, isApproved: 0, createdAt: Date.now(), }).run(); } sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length }); 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({}), }, ); const suggestTasksTool = tool( async (_input: Record) => { 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 ' + 'actionable tasks from the notes (e.g. "set up CI pipeline", "draft proposal for client").\n\n' + 'Return ONLY a valid JSON array of objects with shape { "title": string, "description"?: string, "priority"?: "low"|"medium"|"high", "dueDate"?: string } ' + 'where dueDate is ISO 8601 format (YYYY-MM-DD) if mentioned. Return [] if no actionable tasks are found.\n\n' + 'Example: [{"title":"Set up CI pipeline","description":"Configure GitHub Actions for automated testing","priority":"high"},{"title":"Draft client proposal","dueDate":"2026-03-20"}]', ), new HumanMessage(contextData), ]); const content = typeof result.content === 'string' ? result.content.trim() : '[]'; const match = content.match(/\[[\s\S]*\]/); const jsonStr = match ? match[0] : '[]'; try { const parsed = JSON.parse(jsonStr) as Array<{ title: string; description?: string; priority?: string; dueDate?: string }>; for (const s of parsed) { db.insert(tasks).values({ id: crypto.randomUUID(), projectId, title: s.title, description: s.description ?? null, status: 'todo', priority: s.priority ?? 'medium', assignee: null, dueDate: s.dueDate ? new Date(s.dueDate).getTime() : null, isAiSuggested: 1, isApproved: 0, createdAt: Date.now(), }).run(); } sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length }); return jsonStr; } catch { return '[]'; } }, { name: 'suggest_tasks', description: 'Analyzes project notes for actionable tasks and returns a JSON array of ' + '{ title: string, description?: string, priority?: string, dueDate?: string } suggested tasks. ' + 'Use when the user asks for task suggestions.', schema: z.object({}), }, ); return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool, suggestTasksTool] 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, isAiSuggested: 1, createdAt: Date.now(), }) .run(); sendAction(currentSender, { type: 'task_created', taskId: id }); 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, uiContext?: string): 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. - suggest_tasks: Extract actionable task suggestions from notes. Use when the user asks for task suggestions. When suggest_checkpoints or suggest_tasks returns a JSON array, present the items 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.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`; } function makeGeneralAgentPrompt(contextData: string, withTools = true, uiContext?: string): 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.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`; } function makeKnowledgeAgentPrompt(contextData: string, withTools = true, uiContext?: string): 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.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`; } // --------------------------------------------------------------------------- // LangGraph State // --------------------------------------------------------------------------- const OrchestratorState = Annotation.Root({ /** The user's original message */ userMessage: Annotation(), /** Chat context (global vs project-scoped) */ chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(), /** The route chosen by the orchestrator */ route: Annotation<'project' | 'knowledge' | 'general'>(), /** Messages for the specialist agent */ messages: Annotation({ reducer: (existing, incoming) => Array.isArray(incoming) ? existing.concat(incoming) : existing.concat([incoming]), default: () => [], }), /** The final response text */ response: Annotation(), }); type State = typeof OrchestratorState.State; // --------------------------------------------------------------------------- // Graph nodes // --------------------------------------------------------------------------- /** Node 1: Classify intent — always calls the LLM for every provider */ async function classifyIntent(state: State): Promise> { 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> { 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 blocks instead of using the SDK's API-level mechanism. const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot'; const uiContext = state.chatContext.uiContext; const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext); 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> { 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 uiContext = state.chatContext.uiContext; const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt, uiContext); 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> { 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 uiContext = state.chatContext.uiContext; const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt, uiContext); 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 | 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 }); } function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void { if (!sender || sender.isDestroyed()) return; sender.send(AI_ACTION_CHANNEL, action); } // --------------------------------------------------------------------------- // Orchestrate (public entry point) // --------------------------------------------------------------------------- export async function orchestrate(input: OrchestrateInput): Promise { const { message, context, sender } = input; currentSender = sender; // 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 ... 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('')) { const after = toolCallBuffer.split('').slice(1).join(''); inToolCallBlock = false; toolCallBuffer = ''; const afterClean = after.replace(/^\n/, ''); if (afterClean) sendStreamChunk(sender, afterClean, false); } } else if (text.includes('')) { const before = text.split('')[0]; if (before) sendStreamChunk(sender, before, false); inToolCallBlock = true; toolCallBuffer = '' + text.split('').slice(1).join(''); if (toolCallBuffer.includes('')) { const after = toolCallBuffer.split('').slice(1).join(''); 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 }; } } // --------------------------------------------------------------------------- // Daily Brief (dedicated entry point) // --------------------------------------------------------------------------- const DAILY_BRIEF_PROMPT = `Act as a professional and efficient executive assistant. Give me a concise daily brief for today. Strict Rules: - Adopt a polite, formal, and helpful tone. Do not use emojis, slang, or overly casual encouragement. - Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity. - Do NOT mention zero-counts (e.g., "no overdue items") or general statistics (e.g., "2 active projects", "2 completed tasks"). Only report what needs my attention. - Do NOT include any headers, titles, dates, or greetings. - Do NOT use labels like "Due today:" or "Overdue:". Integrate the information naturally into sentences. - Use **bold** for key phrases, task names, or project names. - Keep the entire response to 3-5 sentences.`; export async function dailyBrief(sender?: Electron.WebContents): Promise { return orchestrate({ message: DAILY_BRIEF_PROMPT, context: { type: 'global' }, sender, }); }