feat: Integrate GitHub Copilot SDK and LangChain for provider-independent orchestration
- Added @github/copilot-sdk and related dependencies for GitHub Copilot integration. - Implemented ChatCopilot adapter for LangChain compatibility. - Created LLM factory to return provider-specific models (OpenAI, Anthropic, Copilot). - Developed Orchestrator agent using LangGraph for intent routing and context assembly. - Enhanced IPC communication for streaming AI responses to the renderer. - Updated progress documentation with implementation details and learnings.
This commit is contained in:
401
src/main/ai/orchestrator.ts
Normal file
401
src/main/ai/orchestrator.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @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, type BaseMessage } from '@langchain/core/messages';
|
||||
import { z } from 'zod';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
||||
import { getLLM } from './llm';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeProjectAgentPrompt(contextData: string): string {
|
||||
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
||||
|
||||
You have access to the following project data:
|
||||
|
||||
${contextData}
|
||||
|
||||
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): string {
|
||||
return `You are @GeneralAgent, an AI assistant for the Adiuva workspace.
|
||||
|
||||
You have access to the following workspace data:
|
||||
|
||||
${contextData}
|
||||
|
||||
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): string {
|
||||
return `You are @KnowledgeAgent, an AI assistant that searches across all project knowledge in Adiuva.
|
||||
|
||||
You have access to the following workspace data:
|
||||
|
||||
${contextData}
|
||||
|
||||
Note: Semantic vector search is not yet available. Answer based on the workspace summary data above.
|
||||
If the user asks about specific note contents that aren't included here, let them know that full cross-project search will be available soon.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LangGraph State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RouteSchema = z.object({
|
||||
route: z.enum(['project', 'knowledge', 'general']).describe(
|
||||
'Which specialist agent should handle this request',
|
||||
),
|
||||
});
|
||||
|
||||
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 using structured output */
|
||||
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 routerLLM = llm.withStructuredOutput(RouteSchema);
|
||||
|
||||
const result = await routerLLM.invoke([
|
||||
new SystemMessage(
|
||||
`You are a routing classifier for Adiuva, a project management workspace.
|
||||
Classify the user's message into one of these 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),
|
||||
]);
|
||||
|
||||
return { route: result.route };
|
||||
}
|
||||
|
||||
/** Node 2a: Project agent — answer 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;
|
||||
const contextData = projectId ? buildProjectContext(projectId) : buildGlobalContext();
|
||||
const systemPrompt = projectId
|
||||
? makeProjectAgentPrompt(contextData)
|
||||
: makeGeneralAgentPrompt(contextData);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Node 2b: Knowledge agent — cross-project 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 systemPrompt = makeKnowledgeAgentPrompt(contextData);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Node 2c: General agent — workspace-wide questions */
|
||||
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 systemPrompt = makeGeneralAgentPrompt(contextData);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 = '';
|
||||
|
||||
for await (const [chunk, metadata] of stream) {
|
||||
// Only stream tokens from the specialist agent nodes (not the classifier)
|
||||
if (
|
||||
metadata.langgraph_node !== 'classifyIntent' &&
|
||||
chunk.content &&
|
||||
typeof chunk.content === 'string'
|
||||
) {
|
||||
fullResponse += chunk.content;
|
||||
sendStreamChunk(sender, chunk.content, 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.' };
|
||||
}
|
||||
|
||||
return { response: '', error: errMsg };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user