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

@@ -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);
const response = await llm.invoke([
// 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),
]);
];
const content = typeof response.content === 'string' ? response.content : '';
return {
messages: [response],
response: content,
};
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 response = await llm.invoke([
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),
]);
];
const content = typeof response.content === 'string' ? response.content : '';
return {
messages: [response],
response: content,
};
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 };
}