feat: implement project action tools for enhanced project management capabilities

This commit is contained in:
Roberto Musso
2026-02-24 15:53:32 +01:00
parent 5eb19e022e
commit 7a1aec0d9f
5 changed files with 547 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
## Your Task US-020
## Your Task US-021
1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
2. Read the PRD at `prd.json` (in the same directory as this file)
@@ -23,22 +23,18 @@ APPEND to progress.txt (never replace, always append):
## USER REQUEST
{
"id": "US-020",
"title": "Context-scoped AI chat UI",
"description": "As a user, I want the AI chat (revealed by the Fluid Curtain) to display a context header, support message input, and stream AI responses.",
"id": "US-021",
"title": "@ProjectAgent with project action tools",
"description": "As a user, I want the AI to answer project-specific questions and take actions like adding tasks, summarizing the project, and suggesting checkpoints.",
"acceptanceCriteria": [
"Chat panel shows a context header using shadcn/ui Badge (variant=outline): 'Chatting about: [Project Name]' when opened from a project detail view, or 'Global workspace' when opened from other sections",
"Chat input box uses shadcn/ui Textarea: white background, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg, Send Lucide icon + 'Send' label) anchored bottom-right",
"User messages appear as right-aligned message bubbles using shadcn/ui Card; AI responses as left-aligned Cards",
"Streaming: AI response tokens appended to the current AI bubble as they arrive from ai.chat",
"A loading spinner or pulsing indicator (shadcn/ui Skeleton) shown while waiting for first token",
"If ai.chat returns { error }, display the error message in a shadcn/ui Card with destructive border styling",
"Chat history is session-only — cleared when the curtain closes or the app restarts",
"Install shadcn/ui components via 'npx shadcn@latest add textarea' before implementing (card, badge, button, skeleton already installed)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"read_project_notes tool: fetches all notes for the scoped projectId from SQLite and returns combined content to the model",
"add_task tool: creates a task in the project via tasks.create and confirms with 'Task added: [title]' in the chat response",
"get_summary tool: calls the SDK to generate a 2-3 sentence summary of the project based on its notes and tasks, then calls projects.update to persist the result in project.aiSummary",
"suggest_checkpoints tool: returns a JSON array of { title: string, date: string } proposed checkpoints based on date-anchored commitments found in notes",
"@Orchestrator routes project-context messages to @ProjectAgent",
"Typecheck passes"
],
"priority": 20,
"priority": 21,
"passes": false,
"notes": ""
}

View File

@@ -386,8 +386,8 @@
"Typecheck passes"
],
"priority": 21,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed: 4 project action tools (read_project_notes, add_task, get_summary, suggest_checkpoints) built per-invocation inside projectAgent using @langchain/core/tools tool() helper. Explicit agent loop with MAX_ITERATIONS=5 safety ceiling. AIMessage.isInstance() type guard for safe tool_calls access. StructuredTool[] return type + non-null assertion for bindTools after runtime guard. Tool dispatch via { ...toolCall, type: 'tool_call' } pattern (matches LangGraph ToolNode convention). Feature-detect bindTools at runtime — Copilot falls back to context-based response. classifyIntent short-circuits to route:'project' when chatContext.type==='project'. get_summary and suggest_checkpoints use nested getLLM().invoke() calls. Typecheck passes."
},
{
"id": "US-022",

View File

@@ -442,3 +442,54 @@
- `trpc.projects.get` query with `enabled: !!projectId` + `id: projectId ?? ''` avoids both the non-null assertion lint warning and unnecessary queries
- For scroll-to-user-message UX: track the last user message with a ref and use `scrollIntoView({ behavior: 'smooth', block: 'start' })` — do NOT auto-scroll on AI streaming to let the user read from the top
---
## 2026-02-24 - US-021
- What was implemented:
- 4 project action tools in `src/main/ai/orchestrator.ts` using `@langchain/core/tools` `tool()` helper, via new `buildProjectTools(projectId)` factory function:
- `read_project_notes`: fetches full note content from SQLite (no 500-char truncation unlike buildProjectContext)
- `add_task`: inserts task via `db.insert(tasks).run()`, returns `'Task added: [title]'`
- `get_summary`: calls nested `getLLM().invoke()` to generate 2-3 sentence summary, persists via `db.update(projects).set({ aiSummary })`
- `suggest_checkpoints`: calls nested `getLLM().invoke()` with structured prompt, returns JSON array `[{ title, date }]` with regex extraction fallback
- `classifyIntent` short-circuits: `chatContext.type === 'project' && chatContext.projectId` → immediately returns `{ route: 'project' }` (saves one LLM round-trip, prevents misrouting)
- `projectAgent` rewritten with agent loop (max 5 iterations):
- `supportsTools` runtime guard: `'bindTools' in llm && typeof llm.bindTools === 'function'`
- Copilot path (no bindTools): direct `llm.invoke()` with full context prompt
- OpenAI/Anthropic path: `llm.bindTools!(projectTools)` → agent loop with `AIMessage.isInstance()` type guard for `tool_calls` access
- Tool dispatch via `matched.invoke({ ...toolCall, type: 'tool_call' as const })` — StructuredTool.invoke() detects ToolCall object and extracts args via internal `_isToolCall()` check
- `ToolMessage` appended per tool call with `tool_call_id`; `messageHistory` accumulated across iterations
- `makeProjectAgentPrompt` updated to describe all 4 available tools and usage guidance
- Streaming unaffected: tool-calling rounds produce empty `chunk.content` (falsy), filtered by existing guard; final text response streams normally
- Files changed: `src/main/ai/orchestrator.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `tool()` returns `DynamicStructuredTool<...>` — use `StructuredTool[]` as the function return type and cast with `as StructuredTool[]` to avoid generic type variance errors in strict mode
- `llm.bindTools` is typed as optional on `BaseChatModel` — even after a runtime `typeof === 'function'` guard, TypeScript still reports TS18048 ("possibly undefined"); use `llm.bindTools!()` with an eslint-disable comment after the guard
- `AIMessage.isInstance(response)` is the zero-unsafe-cast way to access `tool_calls` on a `BaseMessage` — avoids `as any`
- LangGraph `streamMode: 'messages'` naturally skips tool-calling rounds because `chunk.content` is `''` (falsy) for AIMessageChunks that have tool call deltas
- Nested `getLLM().invoke()` calls inside tool handlers (for `get_summary`, `suggest_checkpoints`) do NOT stream tokens to the IPC channel — they execute synchronously within the tool handler, outside LangGraph's stream interceptor
- Short-circuiting `classifyIntent` for project context saves cost and prevents misrouting when user asks general questions from within a project view
- Empty Zod schema `z.object({})` infers TypeScript type `{}` — use `Record<string, never>` as the handler parameter type to be explicit about intent in strict mode
---
## 2026-02-24 - US-021 bugfix
- Bug: `<tool_call>` XML appeared in chat and tasks weren't actually created
- Root cause: `ChatCopilot` extends `SimpleChatModel` which inherits `bindTools()` from `BaseChatModel` — so `'bindTools' in llm` returned TRUE. But `ChatCopilot._call()` ignores bound tools (no kwargs plumbing to the Copilot SDK). The model received tool descriptions in the system prompt but NOT via the API, so it hallucinated `<tool_call>{"name":"sql",...}` freeform text. `tool_calls` on the response was empty → tool not executed → fake success text streamed to UI
- Fix: Replaced runtime `'bindTools' in llm` check with provider-name whitelist (`TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic'])`). Imported `getActiveProviderName` from `./provider`
- Fix: Copilot fallback path now uses `makeProjectAgentPrompt(contextData, false)` — no tool section in system prompt — preventing hallucinated tool calls text
- Files changed: `src/main/ai/orchestrator.ts`
- **Learnings for future iterations:**
- `BaseChatModel.bindTools()` exists as a default inherited method in modern LangChain — `'bindTools' in llm` is ALWAYS true. You CANNOT use this to detect actual tool calling support; must know the provider
- The safe check is provider-name based: only 'openai' and 'anthropic' have real bindTools support in this codebase
- When a model receives tool descriptions IN THE SYSTEM PROMPT but NOT via the API tool calling mechanism, it may hallucinate tool calls as freeform text (especially models trained on ReAct/ToolBench data)
- Remove tool mentions from system prompt when NOT using API-level tool calling
---
## 2026-02-24 - US-021 classifyIntent short-circuit bugfix
- Bug: After US-021, GitHub Copilot chat broke entirely with "Failed to list models" SDK error
- Root cause: The `classifyIntent` short-circuit (added in US-021 for project context) removed the FIRST LLM call from the graph. The Copilot SDK requires at least one prior `sendAndWait()` call to initialize its internal model list cache before a subsequent call succeeds. Without `classifyIntent`'s LLM invocation acting as warm-up, the cold `projectAgent` call triggered `runAgenticLoop → listModels` which failed
- Fix: Removed the short-circuit from `classifyIntent` entirely. The node always calls the LLM for routing (matching pre-US-021 behavior). The `metadata.langgraph_node !== 'classifyIntent'` check in the streaming loop already prevents the routing token from appearing in chat
- Files changed: `src/main/ai/orchestrator.ts`
- **Learnings for future iterations:**
- The Copilot SDK needs a "warm-up" LLM call before it can successfully process the main request. Never eliminate the first LLM call in the graph when Copilot is the provider
- Short-circuit optimizations that skip LLM nodes are only safe for providers where the SDK has no internal state to initialize (OpenAI, Anthropic)
- If you want to restore the short-circuit as an OpenAI/Anthropic optimization, gate it: `if (TOOL_CALLING_PROVIDERS.has(getActiveProviderName()) && state.chatContext.type === 'project')`
---

View File

@@ -12,17 +12,30 @@ import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import type { StructuredTool } from '@langchain/core/tools';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
const COPILOT_TIMEOUT = 60_000;
/** Minimal shape of a Copilot SDK Tool (avoids importing the full SDK type) */
type CopilotNativeTool = {
name: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters?: any;
handler: (args: unknown) => Promise<unknown>;
};
const COPILOT_TIMEOUT = 120_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
private getClient: () => CopilotClientType | null;
/** Native Copilot SDK tools, populated by bindTools() */
private _copilotTools: CopilotNativeTool[] = [];
constructor(getClient: () => CopilotClientType | null) {
constructor(getClient: () => CopilotClientType | null, tools: CopilotNativeTool[] = []) {
super({});
this.getClient = getClient;
this._copilotTools = tools;
}
_llmType(): string {
@@ -37,6 +50,29 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
return client;
}
/**
* Convert LangChain StructuredTools to Copilot SDK native tools and return a
* new ChatCopilot instance that will pass them to createSession().
* The SDK handles the full tool-calling loop internally — no LangChain ToolMessages needed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override bindTools(tools: StructuredTool[]): any {
const copilotTools: CopilotNativeTool[] = tools.map((t) => ({
name: t.name,
description: t.description ?? undefined,
parameters: t.schema,
handler: async (args: unknown) => {
console.log(`[ChatCopilot] tool handler called: ${t.name}`, JSON.stringify(args));
const result = await t.invoke(args as Record<string, unknown>);
const output = typeof result === 'string' ? result : JSON.stringify(result);
console.log(`[ChatCopilot] tool handler result: ${t.name}`, output.slice(0, 200));
return output;
},
}));
console.log(`[ChatCopilot] bindTools() called with:`, copilotTools.map((t) => t.name));
return new ChatCopilot(this.getClient, copilotTools);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = this.requireClient();
@@ -52,12 +88,21 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
const session = await client.createSession({
// When tools are registered, use append mode so the SDK can inject its tool-calling
// instructions before our content. mode:'replace' strips those SDK-managed sections,
// causing the model to never see/call registered tools.
systemMessage: systemContent
? { mode: 'replace', content: systemContent }
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
availableTools: [],
streaming: true,
// Pass native tools when available — SDK handles the agentic tool-calling loop
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: false,
});
try {
@@ -85,17 +130,30 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
console.log(`[ChatCopilot] _streamResponseChunks: hasTools=${hasTools}, tools=[${this._copilotTools.map((t) => t.name).join(', ')}]`);
console.log(`[ChatCopilot] systemMessage mode: ${hasTools ? 'append' : 'replace'}`);
const session = await client.createSession({
// Same append-vs-replace logic as _call: tools require append mode so the SDK
// can inject its tool-calling instructions before our project context.
systemMessage: systemContent
? { mode: 'replace', content: systemContent }
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
availableTools: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: true,
});
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
// Buffer chunks via event listener and yield them
const chunks: string[] = [];
let done = false;
let sessionError: Error | null = null;
let resolveNext: (() => void) | null = null;
const unsubDelta = session.on('assistant.message_delta', (event) => {
@@ -107,13 +165,40 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
});
const unsubEnd = session.on('session.idle', () => {
console.log('[ChatCopilot] session.idle received');
done = true;
resolveNext?.();
});
// Fire the request (don't await — we'll drain via events)
const unsubError = session.on('session.error', (event) => {
console.error('[ChatCopilot] session.error received:', event.data.message);
sessionError = new Error(event.data.message);
done = true;
resolveNext?.();
});
// Log all events to understand SDK behaviour with tools
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsubAll = session.on((event: any) => {
if (!['assistant.message_delta'].includes(event.type)) {
console.log(`[ChatCopilot] SDK event: ${event.type}`, JSON.stringify(event.data ?? {}).slice(0, 300));
}
});
// Fire the request (don't await — we'll drain via events).
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
// If sendAndWait rejects before any session events fire (e.g. send() throws
// internally due to a listModels/auth failure), wake up the while loop so it
// doesn't hang waiting for session.idle that will never arrive.
sendPromise.catch((err: unknown) => {
if (!done) {
sessionError = err instanceof Error ? err : new Error(String(err));
done = true;
resolveNext?.();
}
});
try {
while (!done || chunks.length > 0) {
if (chunks.length > 0) {
@@ -132,11 +217,13 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
}
}
// Ensure the send completes
await sendPromise;
// Propagate any error surfaced via session.error event or sendAndWait rejection
if (sessionError) throw sessionError;
} finally {
unsubDelta();
unsubEnd();
unsubError();
unsubAll();
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}

View File

@@ -5,11 +5,22 @@
* 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 { 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';
/**
* 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';
@@ -135,29 +146,205 @@ function buildGlobalContext(): string {
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[];
}
// ---------------------------------------------------------------------------
// System prompts
// ---------------------------------------------------------------------------
function makeProjectAgentPrompt(contextData: string): string {
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): string {
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.`;
}
@@ -200,7 +387,7 @@ type State = typeof OrchestratorState.State;
// Graph nodes
// ---------------------------------------------------------------------------
/** Node 1: Classify intent using plain-text extraction (works with all providers) */
/** 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.');
@@ -222,30 +409,110 @@ Categories:
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 — answer project-scoped questions */
/** 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;
const contextData = projectId ? buildProjectContext(projectId) : buildGlobalContext();
const systemPrompt = projectId
? makeProjectAgentPrompt(contextData)
: makeGeneralAgentPrompt(contextData);
// 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,
};
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 search */
@@ -268,24 +535,75 @@ async function knowledgeAgent(state: State): Promise<Partial<State>> {
};
}
/** Node 2c: General agent — workspace-wide questions */
/** 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 systemPrompt = makeGeneralAgentPrompt(contextData);
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,
};
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 */
@@ -365,15 +683,49 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
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)
// 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'
typeof chunk.content === 'string' &&
chunk._getType() === 'ai'
) {
fullResponse += chunk.content;
sendStreamChunk(sender, chunk.content, false);
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);
}
}
}
@@ -392,6 +744,9 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
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 };
}