feat: implement project action tools for enhanced project management capabilities
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user