refactor: migrate agent system from XML tool calling to OpenAI SDK with native function calling
- Replace custom XML-based tool parsing (XMLToolExecutor) with OpenAI SDK's native tool_calls via `openai` npm package (dangerouslyAllowBrowser) - Consolidate 4 LLM providers (OpenAI, Claude, Gemini, ClaudeOpenRouter) into a single OpenAI SDK-based LLMProvider compatible with any OpenAI-style API (OpenAI, OpenRouter, Ollama, vLLM) - Move agentic tool execution loop from ChatBox into AgentCore - Update Message type to support tool roles, tool_calls, and tool_call_id - Update system prompt to remove XML formatting instructions (~45% smaller) - Remove AttemptCompletionTool (replaced by stop_reason detection), ThinkTool, and ThinkingTool - Polish tool descriptions for OpenAI function calling schema compliance - Normalize base URLs by stripping /chat/completions suffix
This commit is contained in:
+62
-174
@@ -2,10 +2,6 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
||||
import { UserMessage, AssistantMessage } from './chat';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
|
||||
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
|
||||
import { ClaudeOpenRouterProvider } from '../agent/llm/ClaudeOpenRouterProvider';
|
||||
import { GeminiProvider } from '../agent/llm/GeminiProvider';
|
||||
import { LLMProvider } from '../agent/llm/LLMProvider';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
@@ -14,10 +10,8 @@ import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chat
|
||||
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
|
||||
import { useStreamProcessor } from '../hooks/useStreamProcessor';
|
||||
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
||||
import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils';
|
||||
import { formatLocalDateTime } from '../util/timeUtil';
|
||||
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
||||
import { wrapXmlBlocksInContent } from '../util/xmlUtil';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
@@ -26,24 +20,31 @@ import type { ChatMessage } from '../types/projectTypes';
|
||||
let hasShownWelcomeOnceInRuntime = false;
|
||||
|
||||
/**
|
||||
* Create the appropriate LLM provider based on configuration
|
||||
* Create the LLM provider from current configuration
|
||||
*/
|
||||
const createLLMProvider = (): LLMProvider => {
|
||||
const createLLMProviderFromConfig = (): LLMProvider => {
|
||||
const configManager = ConfigManager.instance();
|
||||
const providerType = configManager.get('general.llm_provider') as string;
|
||||
|
||||
|
||||
let apiKey: string;
|
||||
let model: string;
|
||||
let baseURL: string | undefined;
|
||||
|
||||
switch (providerType) {
|
||||
case 'claude':
|
||||
return new ClaudeProvider();
|
||||
case 'gemini':
|
||||
return new GeminiProvider();
|
||||
case 'claude_openrouter':
|
||||
return new ClaudeOpenRouterProvider();
|
||||
case 'openai_compatible':
|
||||
case 'openai':
|
||||
apiKey = configManager.get('general.openai.api_key') as string;
|
||||
model = configManager.get('general.openai.model') as string;
|
||||
baseURL = undefined; // Uses OpenAI default
|
||||
break;
|
||||
case 'openai_compatible':
|
||||
default:
|
||||
return new OpenAIProvider();
|
||||
apiKey = configManager.get('general.openai_compatible.api_key') as string;
|
||||
model = configManager.get('general.openai_compatible.model') as string;
|
||||
baseURL = configManager.get('general.openai_compatible.base_url') as string || undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
return new LLMProvider(apiKey, model, baseURL);
|
||||
};
|
||||
|
||||
interface ChatBoxProps {
|
||||
@@ -53,17 +54,11 @@ interface ChatBoxProps {
|
||||
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Initialize with empty messages
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
||||
|
||||
// Tool execution state
|
||||
const [isExecutingTools, setIsExecutingTools] = useState(false);
|
||||
const [, setToolResults] = useState<string>(''); // placeholder for future display/use
|
||||
const [, setCurrentToolIndex] = useState<number>(0); // placeholder for future display/use
|
||||
|
||||
|
||||
// Track if this is the first message (for system prompt logging)
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true);
|
||||
|
||||
@@ -77,8 +72,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const handleExportOptionSelect = (option: string) => {
|
||||
if (option === 'Export conversation as JSON') {
|
||||
try {
|
||||
const messages = AgentCore.instance().getAgentState().getMessages();
|
||||
const exportMessages = messages.map((m) => ({
|
||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
||||
const exportMessages = agentMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
@@ -93,25 +88,21 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
} else if (option === 'Export conversation as Markdown') {
|
||||
(async () => {
|
||||
try {
|
||||
const messages = AgentCore.instance().getAgentState().getMessages();
|
||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
||||
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
|
||||
const res = await fetch(templateUrl);
|
||||
const template = await res.text();
|
||||
|
||||
const isAutomatedUserMessage = (content: string): boolean => {
|
||||
return /^tool:\s.*\nsuccess:\s*(true|false)/i.test(content);
|
||||
};
|
||||
|
||||
const sections = messages.map((m) => {
|
||||
const isAutomaticUserMessage = isAutomatedUserMessage(m.content);
|
||||
const roleLabel = m.role === 'assistant' ? 'Assistant' : (isAutomaticUserMessage ? 'User (Automatic)' : 'User');
|
||||
const ts = formatLocalDateTime(new Date(m.timestamp));
|
||||
const contentWithXml = isAutomaticUserMessage ? "```\n" + m.content + "\n```" : wrapXmlBlocksInContent(m.content);
|
||||
return template
|
||||
.replace('{role}', roleLabel)
|
||||
.replace('{timestamp}', ts)
|
||||
.replace('{content}', contentWithXml);
|
||||
});
|
||||
const sections = agentMessages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => {
|
||||
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
|
||||
const ts = formatLocalDateTime(new Date(m.timestamp));
|
||||
return template
|
||||
.replace('{role}', roleLabel)
|
||||
.replace('{timestamp}', ts)
|
||||
.replace('{content}', m.content ?? '');
|
||||
});
|
||||
|
||||
const markdown = sections.join('\n');
|
||||
const filename = `kgstudio-conversation-${buildTimestampSuffix()}.md`;
|
||||
@@ -120,8 +111,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
console.error('Failed to export conversation as Markdown:', err);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
console.log('Chat export selected:', option);
|
||||
}
|
||||
setShowExportDropdown(false);
|
||||
};
|
||||
@@ -135,6 +124,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
setMessages(prev => [...prev, message]);
|
||||
}, []);
|
||||
|
||||
const handleMessageRemove = useCallback((messageId: string) => {
|
||||
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||||
}, []);
|
||||
|
||||
const handleProcessingChange = useCallback((processing: boolean) => {
|
||||
setIsProcessing(processing);
|
||||
}, []);
|
||||
@@ -143,17 +136,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const streamProcessor = useStreamProcessor({
|
||||
onMessageUpdate: handleMessageUpdate,
|
||||
onMessageAdd: handleMessageAdd,
|
||||
onMessageRemove: handleMessageRemove,
|
||||
onProcessingChange: handleProcessingChange
|
||||
});
|
||||
|
||||
const clearChatUI = useCallback(async () => {
|
||||
// Clear UI state
|
||||
setMessages([]);
|
||||
|
||||
// Reset first message flag so system prompt will be logged again
|
||||
setIsFirstMessage(true);
|
||||
|
||||
// Auto-show welcome message after clearing (like on app startup)
|
||||
|
||||
const welcomeMessage = await addWelcomeMessage();
|
||||
if (welcomeMessage) {
|
||||
setMessages([welcomeMessage]);
|
||||
@@ -164,47 +154,37 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
useEffect(() => {
|
||||
const initializeProvider = async () => {
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
// Ensure ConfigManager is initialized
|
||||
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
|
||||
const applyProviderFromConfig = () => {
|
||||
const provider = createLLMProvider();
|
||||
const provider = createLLMProviderFromConfig();
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.setLLMProvider(provider);
|
||||
console.log(`Switched to ${provider.name} provider`);
|
||||
console.log('LLM provider configured');
|
||||
};
|
||||
|
||||
// Initial apply
|
||||
applyProviderFromConfig();
|
||||
|
||||
// Subscribe to config changes to hot-swap providers
|
||||
const unsubscribe = configManager.addChangeListener((changedKeys) => {
|
||||
// Hot-swap on provider change or when relevant provider config changes
|
||||
if (
|
||||
changedKeys.includes('general.llm_provider') ||
|
||||
changedKeys.some(k => k.startsWith('general.openai.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.openai_compatible.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.gemini.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.claude.'))
|
||||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
|
||||
) {
|
||||
applyProviderFromConfig();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup subscription on unmount
|
||||
return unsubscribe;
|
||||
};
|
||||
|
||||
// Register the UI clear callback for external components to use
|
||||
|
||||
registerClearChatUICallback(clearChatUI);
|
||||
|
||||
|
||||
const maybeUnsubscribePromise = initializeProvider();
|
||||
|
||||
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
|
||||
|
||||
(async () => {
|
||||
if (hasShownWelcomeOnceInRuntime) return;
|
||||
hasShownWelcomeOnceInRuntime = true;
|
||||
@@ -214,7 +194,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
})();
|
||||
// In case initializeProvider returned a cleanup, ensure we call it
|
||||
|
||||
return () => {
|
||||
Promise.resolve(maybeUnsubscribePromise).then((cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
@@ -226,17 +206,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const controller = streamProcessor.abortController;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
|
||||
// Use AgentCore to clean up the data model and get the user message content
|
||||
|
||||
const agentCore = AgentCore.instance();
|
||||
const userMessageContent = agentCore.abortCurrentRequest();
|
||||
|
||||
// Remove the last user message and assistant message from UI
|
||||
|
||||
setMessages(prev => prev.slice(0, -2));
|
||||
|
||||
// Restore the user's input (use the content from AgentCore if available)
|
||||
setInputValue(userMessageContent || lastUserMessage);
|
||||
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
@@ -246,101 +221,29 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
};
|
||||
|
||||
const executeToolsFromResponse = async (response: string): Promise<boolean> => {
|
||||
try {
|
||||
const actionableBlocks = extractActionableTools(response);
|
||||
|
||||
if (actionableBlocks.length === 0) {
|
||||
// No actionable tools to execute, stop the loop
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start tool execution phase
|
||||
setIsExecutingTools(true);
|
||||
setToolResults('');
|
||||
setCurrentToolIndex(0);
|
||||
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
|
||||
// Execute all tools and get accumulated results
|
||||
const accumulatedResults = await executeAllTools(actionableBlocks, {
|
||||
onMessageAdd: handleMessageAdd,
|
||||
onStatusUpdate: setStatus
|
||||
});
|
||||
|
||||
// Store accumulated results
|
||||
setToolResults(accumulatedResults);
|
||||
setIsExecutingTools(false);
|
||||
|
||||
// Check if agent is still working on task before sending results to LLM
|
||||
const agentCore = AgentCore.instance();
|
||||
const isStillWorkingOnTask = agentCore.getAgentState().getIsWorkingOnTask();
|
||||
|
||||
if (isStillWorkingOnTask) {
|
||||
// Send tool results back to LLM
|
||||
setStatus('Processing tool results...');
|
||||
await sendToolResultsToLLM(accumulatedResults);
|
||||
} else {
|
||||
// Agent is no longer working on task, ignore results and return control to user
|
||||
setStatus('Tool execution completed');
|
||||
}
|
||||
|
||||
return true; // Tools were found and executed
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error executing tools:', error);
|
||||
setIsExecutingTools(false);
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
setStatus(`Tool execution failed: ${error}`);
|
||||
return false; // Tool execution failed
|
||||
}
|
||||
};
|
||||
|
||||
const sendToolResultsToLLM = async (toolResultsString: string): Promise<void> => {
|
||||
// Process tool results through the stream processor
|
||||
const assistantResponse = await streamProcessor.processStream(toolResultsString, 'TOOL_RESULTS');
|
||||
|
||||
// Check if the new response contains more tools
|
||||
const hasMoreTools = await executeToolsFromResponse(assistantResponse);
|
||||
|
||||
// If no more tools were found, set working flag to false
|
||||
if (!hasMoreTools) {
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.getAgentState().setIsWorkingOnTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (inputValue.trim() && !isProcessing) {
|
||||
const userMessage = inputValue.trim();
|
||||
setLastUserMessage(userMessage);
|
||||
setInputValue('');
|
||||
|
||||
// Run message through the filter system
|
||||
const filterResult = await processUserMessage(userMessage);
|
||||
|
||||
// Conditionally show the user message bubble
|
||||
if (filterResult.displayUserMessage) {
|
||||
const userMsgObject = createMessage('user', userMessage);
|
||||
handleMessageAdd(userMsgObject);
|
||||
}
|
||||
|
||||
// If we have a pseudo assistant response, show it immediately
|
||||
if (filterResult.pseudoAssistantResponse) {
|
||||
const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse);
|
||||
handleMessageAdd(pseudoMessage);
|
||||
}
|
||||
|
||||
// If we shouldn't send anything to LLM, stop here
|
||||
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set working on task flag when user sends a message
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.getAgentState().setIsWorkingOnTask(true);
|
||||
|
||||
// Log system prompt only for first message or first message after clear
|
||||
// Log system prompt only for first message
|
||||
if (isFirstMessage) {
|
||||
try {
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
@@ -350,20 +253,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
} catch (error) {
|
||||
console.error('Failed to log system prompt:', error);
|
||||
}
|
||||
// Mark that we've logged the system prompt for this conversation
|
||||
setIsFirstMessage(false);
|
||||
}
|
||||
|
||||
// Process user input through the stream processor
|
||||
const assistantResponse = await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
|
||||
|
||||
// Check if response contains tools to execute
|
||||
const hasTools = await executeToolsFromResponse(assistantResponse);
|
||||
|
||||
// If no tools were found, set working flag to false and return control to user
|
||||
if (!hasTools) {
|
||||
agentCore.getAgentState().setIsWorkingOnTask(false);
|
||||
}
|
||||
// Process through stream processor — AgentCore handles the full agentic loop internally
|
||||
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -372,18 +266,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Allow Shift+Enter for new lines (default textarea behavior)
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
// Set a data attribute on the textarea to help global keyboard handler identify it
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.setAttribute('data-chatbox-input', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Remove the data attribute when losing focus
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.removeAttribute('data-chatbox-input');
|
||||
}
|
||||
@@ -403,18 +294,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto-focus input when it becomes visible (when processing and tool execution complete)
|
||||
// Auto-focus input when processing completes
|
||||
useEffect(() => {
|
||||
if (!isProcessing && !isExecutingTools && textareaRef.current) {
|
||||
// Use a small delay to ensure the DOM has updated
|
||||
if (!isProcessing && textareaRef.current) {
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
// Also scroll to bottom when input becomes visible after tool execution
|
||||
// This ensures proper scroll position after layout changes from showing input box
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
}
|
||||
}, [isProcessing, isExecutingTools]);
|
||||
}, [isProcessing]);
|
||||
|
||||
return (
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
@@ -463,14 +351,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="chatbox-messages">
|
||||
{messages.map((message) => (
|
||||
message.role === 'user' ? (
|
||||
<UserMessage key={message.id} content={message.content} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
@@ -479,8 +367,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!isProcessing && !isExecutingTools && (
|
||||
|
||||
{!isProcessing && (
|
||||
<div className="chatbox-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -499,4 +387,4 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChatBox);
|
||||
export default memo(ChatBox);
|
||||
|
||||
Reference in New Issue
Block a user