feat: implement project action tools for enhanced project management capabilities
This commit is contained in:
@@ -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)
|
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)
|
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
|
## USER REQUEST
|
||||||
{
|
{
|
||||||
"id": "US-020",
|
"id": "US-021",
|
||||||
"title": "Context-scoped AI chat UI",
|
"title": "@ProjectAgent with project action tools",
|
||||||
"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.",
|
"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": [
|
"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",
|
"read_project_notes tool: fetches all notes for the scoped projectId from SQLite and returns combined content to the model",
|
||||||
"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",
|
"add_task tool: creates a task in the project via tasks.create and confirms with 'Task added: [title]' in the chat response",
|
||||||
"User messages appear as right-aligned message bubbles using shadcn/ui Card; AI responses as left-aligned Cards",
|
"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",
|
||||||
"Streaming: AI response tokens appended to the current AI bubble as they arrive from ai.chat",
|
"suggest_checkpoints tool: returns a JSON array of { title: string, date: string } proposed checkpoints based on date-anchored commitments found in notes",
|
||||||
"A loading spinner or pulsing indicator (shadcn/ui Skeleton) shown while waiting for first token",
|
"@Orchestrator routes project-context messages to @ProjectAgent",
|
||||||
"If ai.chat returns { error }, display the error message in a shadcn/ui Card with destructive border styling",
|
"Typecheck passes"
|
||||||
"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"
|
|
||||||
],
|
],
|
||||||
"priority": 20,
|
"priority": 21,
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
4
prd.json
4
prd.json
@@ -386,8 +386,8 @@
|
|||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 21,
|
"priority": 21,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"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",
|
"id": "US-022",
|
||||||
|
|||||||
51
progress.txt
51
progress.txt
@@ -442,3 +442,54 @@
|
|||||||
- `trpc.projects.get` query with `enabled: !!projectId` + `id: projectId ?? ''` avoids both the non-null assertion lint warning and unnecessary queries
|
- `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
|
- 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')`
|
||||||
|
---
|
||||||
|
|||||||
@@ -12,17 +12,30 @@ import type { BaseMessage } from '@langchain/core/messages';
|
|||||||
import { AIMessageChunk } from '@langchain/core/messages';
|
import { AIMessageChunk } from '@langchain/core/messages';
|
||||||
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
||||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||||
|
import type { StructuredTool } from '@langchain/core/tools';
|
||||||
|
|
||||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
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> {
|
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
||||||
private getClient: () => CopilotClientType | null;
|
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({});
|
super({});
|
||||||
this.getClient = getClient;
|
this.getClient = getClient;
|
||||||
|
this._copilotTools = tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
_llmType(): string {
|
_llmType(): string {
|
||||||
@@ -37,6 +50,29 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
|||||||
return client;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
|
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
|
||||||
const client = this.requireClient();
|
const client = this.requireClient();
|
||||||
@@ -52,12 +88,21 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
|||||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
|
const hasTools = this._copilotTools.length > 0;
|
||||||
|
|
||||||
const session = await client.createSession({
|
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
|
systemMessage: systemContent
|
||||||
? { mode: 'replace', content: systemContent }
|
? hasTools
|
||||||
|
? { content: systemContent }
|
||||||
|
: { mode: 'replace', content: systemContent }
|
||||||
: undefined,
|
: undefined,
|
||||||
availableTools: [],
|
// Pass native tools when available — SDK handles the agentic tool-calling loop
|
||||||
streaming: true,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||||
|
streaming: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -85,17 +130,30 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
|||||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||||
.join('\n');
|
.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({
|
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
|
systemMessage: systemContent
|
||||||
? { mode: 'replace', content: systemContent }
|
? hasTools
|
||||||
|
? { content: systemContent }
|
||||||
|
: { mode: 'replace', content: systemContent }
|
||||||
: undefined,
|
: undefined,
|
||||||
availableTools: [],
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||||
streaming: true,
|
streaming: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
|
||||||
|
|
||||||
// Buffer chunks via event listener and yield them
|
// Buffer chunks via event listener and yield them
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let done = false;
|
let done = false;
|
||||||
|
let sessionError: Error | null = null;
|
||||||
let resolveNext: (() => void) | null = null;
|
let resolveNext: (() => void) | null = null;
|
||||||
|
|
||||||
const unsubDelta = session.on('assistant.message_delta', (event) => {
|
const unsubDelta = session.on('assistant.message_delta', (event) => {
|
||||||
@@ -107,13 +165,40 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const unsubEnd = session.on('session.idle', () => {
|
const unsubEnd = session.on('session.idle', () => {
|
||||||
|
console.log('[ChatCopilot] session.idle received');
|
||||||
done = true;
|
done = true;
|
||||||
resolveNext?.();
|
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);
|
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 {
|
try {
|
||||||
while (!done || chunks.length > 0) {
|
while (!done || chunks.length > 0) {
|
||||||
if (chunks.length > 0) {
|
if (chunks.length > 0) {
|
||||||
@@ -132,11 +217,13 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the send completes
|
// Propagate any error surfaced via session.error event or sendAndWait rejection
|
||||||
await sendPromise;
|
if (sessionError) throw sessionError;
|
||||||
} finally {
|
} finally {
|
||||||
unsubDelta();
|
unsubDelta();
|
||||||
unsubEnd();
|
unsubEnd();
|
||||||
|
unsubError();
|
||||||
|
unsubAll();
|
||||||
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@
|
|||||||
* The LLM is a swappable connector obtained via `getLLM()`.
|
* The LLM is a swappable connector obtained via `getLLM()`.
|
||||||
*/
|
*/
|
||||||
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
|
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 { eq, asc } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
import { getDb } from '../db';
|
import { getDb } from '../db';
|
||||||
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
||||||
import { getLLM } from './llm';
|
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';
|
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||||
|
|
||||||
@@ -135,29 +146,205 @@ function buildGlobalContext(): string {
|
|||||||
return lines.join('\n');
|
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
|
// 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.
|
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
||||||
|
|
||||||
You have access to the following project data:
|
You have access to the following project data:
|
||||||
|
|
||||||
${contextData}
|
${contextData}
|
||||||
|
${toolsSection}
|
||||||
Answer the user's question based on this project context. Be concise and helpful.
|
Answer the user's question based on this project context. Be concise and helpful.
|
||||||
When referencing tasks, notes, or checkpoints, mention them by name.
|
When referencing tasks, notes, or checkpoints, mention them by name.
|
||||||
If you don't have enough information, say so.`;
|
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.
|
return `You are @GeneralAgent, an AI assistant for the Adiuva workspace.
|
||||||
|
|
||||||
You have access to the following workspace data:
|
You have access to the following workspace data:
|
||||||
|
|
||||||
${contextData}
|
${contextData}
|
||||||
|
${toolsSection}
|
||||||
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
||||||
When discussing tasks or projects, reference them by name.`;
|
When discussing tasks or projects, reference them by name.`;
|
||||||
}
|
}
|
||||||
@@ -200,7 +387,7 @@ type State = typeof OrchestratorState.State;
|
|||||||
// Graph nodes
|
// 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>> {
|
async function classifyIntent(state: State): Promise<Partial<State>> {
|
||||||
const llm = await getLLM();
|
const llm = await getLLM();
|
||||||
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
|
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 validRoutes = ['project', 'knowledge', 'general'] as const;
|
||||||
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
|
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
|
||||||
|
|
||||||
|
console.log(`[Orchestrator] classifyIntent → route="${route}" (raw="${text}")`);
|
||||||
return { route };
|
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>> {
|
async function projectAgent(state: State): Promise<Partial<State>> {
|
||||||
const llm = await getLLM();
|
const llm = await getLLM();
|
||||||
if (!llm) throw new Error('AI provider not configured.');
|
if (!llm) throw new Error('AI provider not configured.');
|
||||||
|
|
||||||
const projectId = state.chatContext.projectId;
|
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 SystemMessage(systemPrompt),
|
||||||
new HumanMessage(state.userMessage),
|
new HumanMessage(state.userMessage),
|
||||||
]);
|
];
|
||||||
|
|
||||||
const content = typeof response.content === 'string' ? response.content : '';
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||||
return {
|
const response = await llmWithTools.invoke(messageHistory);
|
||||||
messages: [response],
|
messageHistory.push(response);
|
||||||
response: content,
|
|
||||||
};
|
// 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 */
|
/** 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>> {
|
async function generalAgent(state: State): Promise<Partial<State>> {
|
||||||
const llm = await getLLM();
|
const llm = await getLLM();
|
||||||
if (!llm) throw new Error('AI provider not configured.');
|
if (!llm) throw new Error('AI provider not configured.');
|
||||||
|
|
||||||
const contextData = buildGlobalContext();
|
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 SystemMessage(systemPrompt),
|
||||||
new HumanMessage(state.userMessage),
|
new HumanMessage(state.userMessage),
|
||||||
]);
|
];
|
||||||
|
|
||||||
const content = typeof response.content === 'string' ? response.content : '';
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||||
return {
|
const response = await llmWithTools.invoke(messageHistory);
|
||||||
messages: [response],
|
messageHistory.push(response);
|
||||||
response: content,
|
|
||||||
};
|
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 */
|
/** 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 = '';
|
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) {
|
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 (
|
if (
|
||||||
metadata.langgraph_node !== 'classifyIntent' &&
|
metadata.langgraph_node !== 'classifyIntent' &&
|
||||||
chunk.content &&
|
chunk.content &&
|
||||||
typeof chunk.content === 'string'
|
typeof chunk.content === 'string' &&
|
||||||
|
chunk._getType() === 'ai'
|
||||||
) {
|
) {
|
||||||
fullResponse += chunk.content;
|
const text = chunk.content as string;
|
||||||
sendStreamChunk(sender, chunk.content, false);
|
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')) {
|
if (errMsg.includes('timeout') || errMsg.includes('Timeout')) {
|
||||||
return { response: '', error: 'Request timed out. Please try again.' };
|
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 };
|
return { response: '', error: errMsg };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user