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:
Xiaohan-Tian
2026-04-05 18:59:25 -07:00
parent 597fb9a292
commit 2cd976ff96
32 changed files with 697 additions and 2310 deletions
+135 -72
View File
@@ -1,113 +1,188 @@
import { LLMProvider } from '../llm/LLMProvider';
import { AgentState } from './AgentState';
import { SystemPrompts } from './SystemPrompts';
import { AVAILABLE_TOOLS } from '../tools';
import { useProjectStore } from '../../stores/projectStore';
import type { StreamChunk } from '../llm/StreamingTypes';
import type { ToolCall } from './AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
/**
* Main orchestrator for the AI agent system
* Main orchestrator for the AI agent system.
* Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat.
*/
export class AgentCore {
private static _instance: AgentCore | null = null;
private llmProvider: LLMProvider | null = null;
private agentState: AgentState;
private currentUserMessageId: string | null = null;
private currentAssistantMessageId: string | null = null;
private constructor() {
this.agentState = new AgentState();
}
/**
* Get the singleton instance
*/
static instance(): AgentCore {
if (!AgentCore._instance) {
AgentCore._instance = new AgentCore();
}
return AgentCore._instance;
}
/**
* Set the LLM provider
*/
setLLMProvider(provider: LLMProvider): void {
this.llmProvider = provider;
}
/**
* Get the current LLM provider
*/
getLLMProvider(): LLMProvider | null {
return this.llmProvider;
}
/**
* Get the agent state
*/
getAgentState(): AgentState {
return this.agentState;
}
/**
* Process user input and generate streaming response
* Get OpenAI tool definitions for all available tools
*/
private getToolDefinitions(): OpenAIToolDefinition[] {
return Object.values(AVAILABLE_TOOLS).map(ToolClass => {
const tool = new ToolClass();
return tool.getDefinition();
});
}
/**
* Execute a single tool call and return the result
*/
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
const toolName = toolCall.function.name;
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
if (!ToolClass) {
return { success: false, result: `Unknown tool: ${toolName}` };
}
try {
const params = JSON.parse(toolCall.function.arguments);
const toolInstance = new ToolClass();
const result = await toolInstance.execute(params);
// Sync UI state after successful tool execution
if (result.success) {
useProjectStore.getState().refreshProjectState();
}
return result;
} catch (error) {
return { success: false, result: `Tool execution failed: ${error}` };
}
}
/**
* Process user input and generate streaming response.
* Handles the full agentic loop internally: if the LLM returns tool_calls,
* execute them and feed results back until the LLM produces a final text response.
*/
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
if (!this.llmProvider) {
throw new Error('No LLM provider configured');
}
// Add user message to state and track its ID
// Add user message to state
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
// Get system prompt with current context
const systemPrompt = await SystemPrompts.getSystemPromptWithContext();
// Get full conversation history with preserved roles
const conversationHistory = this.agentState.getMessages();
// Generate streaming response with full conversation context
let assistantResponse = '';
// Pre-add an empty assistant message that we'll update as we stream
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
const tools = this.getToolDefinitions();
try {
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt)) {
if (chunk.type === 'text') {
assistantResponse += chunk.content;
// Update the assistant message in real-time
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
// Agentic loop: stream → check for tool calls → execute → repeat
let continueLoop = true;
while (continueLoop) {
const conversationHistory = this.agentState.getMessages();
// Pre-add an empty assistant message that we'll update as we stream
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
let assistantTextContent = '';
const accumulatedToolCalls: ToolCall[] = [];
let finishReason = 'stop';
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt, tools)) {
if (chunk.type === 'text') {
assistantTextContent += chunk.content;
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
yield chunk;
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
accumulatedToolCalls.push(chunk.toolCall);
} else if (chunk.type === 'done') {
finishReason = chunk.finishReason ?? 'stop';
}
}
if (finishReason === 'tool_calls' && accumulatedToolCalls.length > 0) {
// Update assistant message with tool calls
this.agentState.updateMessage(
this.currentAssistantMessageId,
assistantTextContent || null,
{ tool_calls: accumulatedToolCalls }
);
// Execute each tool call and add results to conversation
for (const toolCall of accumulatedToolCalls) {
// Notify UI about the tool call
yield { type: 'tool_call', content: '', toolCall };
const result = await this.executeTool(toolCall);
// Add tool result message to conversation history
this.agentState.addMessage('tool', JSON.stringify(result), {
tool_call_id: toolCall.id,
});
// Notify UI about the tool result
yield {
type: 'tool_result',
content: '',
toolResult: {
name: toolCall.function.name,
success: result.success,
result: result.result,
},
};
}
// Clear assistant message ID before next iteration creates a new one
this.currentAssistantMessageId = null;
// Continue loop — send tool results back to LLM
} else {
// LLM finished with text response (stop reason)
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
continueLoop = false;
}
yield chunk;
}
// Final update to ensure the complete response is stored
if (assistantResponse) {
this.agentState.updateMessage(this.currentAssistantMessageId, assistantResponse);
}
yield { type: 'done', content: '', finishReason: 'stop' };
} finally {
// Clear the current message IDs when done (successfully or not)
this.currentUserMessageId = null;
this.currentAssistantMessageId = null;
}
}
/**
* Abort the current streaming request and clean up messages
* Returns the content of the user message that was aborted (for restoring to input)
*/
abortCurrentRequest(): string | null {
let userMessageContent = null;
// Remove the current assistant message (the "in progress" one)
if (this.currentAssistantMessageId) {
this.agentState.removeMessage(this.currentAssistantMessageId);
this.currentAssistantMessageId = null;
}
// Remove the current user message and get its content for restoration
if (this.currentUserMessageId) {
const messages = this.agentState.getMessages();
const userMessage = messages.find(msg => msg.id === this.currentUserMessageId);
@@ -117,35 +192,23 @@ export class AgentCore {
this.agentState.removeMessage(this.currentUserMessageId);
this.currentUserMessageId = null;
}
return userMessageContent;
}
/**
* Check if there's a current streaming request in progress
*/
isStreamingInProgress(): boolean {
return this.currentUserMessageId !== null && this.currentAssistantMessageId !== null;
return this.currentUserMessageId !== null;
}
/**
* Clear the conversation history
*/
clearConversation(): void {
this.agentState.clearMessages();
}
/**
* Get whether the agent is currently working on a task
*/
getIsWorkingOnTask(): boolean {
return this.agentState.getIsWorkingOnTask();
}
/**
* Set whether the agent is currently working on a task
*/
setIsWorkingOnTask(isWorking: boolean): void {
this.agentState.setIsWorkingOnTask(isWorking);
}
}
}
+41 -18
View File
@@ -2,50 +2,73 @@
* Manages the state of an agent conversation
*/
/**
* Tool call info attached to assistant messages (OpenAI function calling format)
*/
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string of parameters
};
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
role: 'user' | 'assistant' | 'tool';
content: string | null;
timestamp: number;
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
}
export class AgentState {
private messages: Message[] = [];
private conversationId: string;
private isWorkingOnTask: boolean = false;
constructor(conversationId?: string, isWorkingOnTask: boolean = false) {
this.conversationId = conversationId || this.generateConversationId();
this.isWorkingOnTask = isWorkingOnTask;
}
/**
* Add a message to the conversation
*/
addMessage(role: 'user' | 'assistant', content: string): string {
addMessage(
role: 'user' | 'assistant' | 'tool',
content: string | null,
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
): string {
const message: Message = {
id: this.generateMessageId(),
role,
content,
timestamp: Date.now()
timestamp: Date.now(),
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
};
this.messages.push(message);
return message.id;
}
/**
* Update the content of a message by ID
*/
updateMessage(messageId: string, content: string): boolean {
updateMessage(messageId: string, content: string | null, options?: { tool_calls?: ToolCall[] }): boolean {
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) {
this.messages[messageIndex].content = content;
if (options?.tool_calls) {
this.messages[messageIndex].tool_calls = options.tool_calls;
}
return true;
}
return false;
}
/**
* Remove a message by ID
*/
@@ -57,46 +80,46 @@ export class AgentState {
}
return false;
}
/**
* Remove the last N messages
*/
removeLastMessages(count: number): void {
this.messages.splice(-count, count);
}
/**
* Get all messages in the conversation
*/
getMessages(): Message[] {
return [...this.messages];
}
/**
* Get the conversation ID
*/
getConversationId(): string {
return this.conversationId;
}
/**
* Clear all messages
*/
clearMessages(): void {
this.messages = [];
}
/**
* Get the last N messages
*/
getRecentMessages(count: number): Message[] {
return this.messages.slice(-count);
}
private generateConversationId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
private generateMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
@@ -109,4 +132,4 @@ export class AgentState {
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
this.isWorkingOnTask = isWorkingOnTask;
}
}
}
-389
View File
@@ -1,389 +0,0 @@
/**
* XMLToolExecutor - Bridge between XML tool invocations and the existing tool system
* Parses XML blocks from LLM responses and executes corresponding tools
*/
import { extractXMLFromString } from '../../util/xmlUtil';
import { AVAILABLE_TOOLS, type ToolName } from '../tools';
import type { BaseTool, ToolResult } from '../tools/BaseTool';
import { useProjectStore } from '../../stores/projectStore';
/**
* Main executor class for XML-based tool invocations
* Integrates with existing tool architecture and streaming types
*/
export class XMLToolExecutor {
// Private static instance for singleton pattern
private static _instance: XMLToolExecutor | null = null;
// Private constructor to prevent direct instantiation
private constructor() {}
/**
* Get the singleton instance of XMLToolExecutor
*/
public static instance(): XMLToolExecutor {
if (!XMLToolExecutor._instance) {
XMLToolExecutor._instance = new XMLToolExecutor();
}
return XMLToolExecutor._instance;
}
/**
* Execute all XML tool invocations found in the given input string
* @param input - String containing XML tool invocations (typically LLM response)
* @returns Promise resolving to array of tool results in order of appearance
*/
public async executeXMLTools(input: string): Promise<ToolResult[]> {
try {
// Extract all XML blocks from the input
const xmlBlocks = extractXMLFromString(input);
if (xmlBlocks.length === 0) {
return [];
}
// Process each XML block and collect results
const results: ToolResult[] = [];
for (const xmlBlock of xmlBlocks) {
try {
const result = await this.executeXMLBlock(xmlBlock);
results.push(result);
} catch (error) {
// Create failed result
results.push({
success: false,
result: `Failed to process XML block: ${error}`
});
}
}
return results;
} catch (error) {
return [{
success: false,
result: `Failed to execute XML tools: ${error}`
}];
}
}
/**
* Execute a single XML block as a tool invocation
* @param xmlBlock - XML string representing a tool invocation
* @returns Promise resolving to tool execution result
*/
private async executeXMLBlock(xmlBlock: string): Promise<ToolResult> {
// Parse XML to extract tool information
const parseResult = this.parseXMLBlock(xmlBlock);
if (!parseResult.success) {
return {
success: false,
result: parseResult.error || 'Failed to parse XML block'
};
}
// Check if tool exists in registry
if (!(parseResult.toolName in AVAILABLE_TOOLS)) {
return {
success: false,
result: `Unknown tool: ${parseResult.toolName}`
};
}
try {
// Create tool instance
const ToolClass = AVAILABLE_TOOLS[parseResult.toolName as ToolName];
const toolInstance: BaseTool = new ToolClass();
// Execute the tool
const toolResult = await toolInstance.execute(parseResult.parameters);
// Sync UI state if the tool execution was successful
if (toolResult.success) {
this.syncUIState();
}
return toolResult;
} catch (error) {
return {
success: false,
result: `Tool execution failed: ${error}`
};
}
}
/**
* Parse XML block to extract tool name and parameters
* @param xmlBlock - XML string to parse
* @returns Parse result with tool information or error
*/
private parseXMLBlock(xmlBlock: string): { success: boolean; toolName: string; parameters: Record<string, unknown>; error?: string } {
try {
// Special pre-processing for attempt_completion: ensure <comment> is wrapped in CDATA
const preparedXml = this.preprocessAttemptCompletionXML(xmlBlock);
// Parse XML using native DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(preparedXml, 'text/xml');
// Check for parsing errors
const parserError = doc.querySelector('parsererror');
if (parserError) {
return {
success: false,
toolName: '',
parameters: {},
error: `XML parsing error: ${parserError.textContent}`
};
}
// Get the root element (tool name)
const rootElement = doc.documentElement;
const toolName = rootElement.tagName;
// Parse XML parameters
const parameters = this.parseXMLParameters(rootElement);
return {
success: true,
toolName,
parameters
};
} catch (error) {
return {
success: false,
toolName: '',
parameters: {},
error: `Failed to parse XML: ${error}`
};
}
}
/**
* Ensure special tools have CDATA-wrapped content where appropriate.
* - attempt_completion: wrap <comment> inner text with CDATA (if not already)
* - think / thinking: wrap root inner text with CDATA (if not already)
* Decode basic XML entities before wrapping so CDATA contains human-readable text.
*/
private preprocessAttemptCompletionXML(xml: string): string {
try {
const leadingWhitespaceMatch = xml.match(/^\s*/);
const prefix = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
const withoutLeading = xml.slice(prefix.length);
const rootMatch = withoutLeading.match(/^<([A-Za-z_][\w-]*)\b/);
const root = rootMatch?.[1] || '';
if (root !== 'attempt_completion' && root !== 'think' && root !== 'thinking') return xml;
// Helper to decode entities
const decodeEntities = (text: string): string =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
if (root === 'attempt_completion') {
// Find first <comment>...</comment>
const commentRegex = /<comment>([\s\S]*?)<\/comment>/i;
const match = xml.match(commentRegex);
if (!match) return xml;
const inner = match[1];
if (/<!\[CDATA\[/.test(inner)) {
// Already wrapped
return xml;
}
const decoded = decodeEntities(inner);
const replacement = `<comment><![CDATA[${decoded}]]></comment>`;
return xml.replace(commentRegex, replacement);
}
// Handle <think>...</think> or <thinking>...</thinking>
const rootRegex = new RegExp(`<${root}>([\\s\\S]*?)</${root}>`, 'i');
const rootMatchContent = xml.match(rootRegex);
if (!rootMatchContent) return xml;
const innerRoot = rootMatchContent[1];
if (/<!\[CDATA\[/.test(innerRoot)) {
return xml; // Already wrapped
}
const decodedRoot = decodeEntities(innerRoot);
const replacementRoot = `<${root}><![CDATA[${decodedRoot}]]></${root}>`;
return xml.replace(rootRegex, replacementRoot);
} catch {
// On any error, return original XML to avoid breaking flow
return xml;
}
}
/**
* Parse XML element into tool parameters object
* Converts XML structure to JavaScript object that matches tool parameter schema
* @param element - Root XML element containing tool parameters
* @returns Parameters object for tool execution
*/
private parseXMLParameters(element: Element): Record<string, unknown> {
const parameters: Record<string, unknown> = {};
// Special handling for thinking tool: if no child elements, use text content directly
if (element.tagName === 'thinking' && element.children.length === 0) {
const textContent = element.textContent?.trim() || '';
parameters.content = textContent;
return parameters;
}
// Process all child elements
for (const child of element.children) {
const paramName = child.tagName;
const paramValue = this.parseXMLValue(child);
// Handle arrays (multiple elements with same tag name)
if (parameters[paramName] !== undefined) {
// Convert to array if not already
if (!Array.isArray(parameters[paramName])) {
parameters[paramName] = [parameters[paramName]];
}
(parameters[paramName] as unknown[]).push(paramValue);
} else {
parameters[paramName] = paramValue;
}
}
// Apply array wrapper flattening
return this.flattenArrayWrappers(parameters);
}
/**
* Flatten array wrapper patterns in parsed parameters
* Converts structures like {notes: {note: [...]}} to {notes: [...]}
* @param parameters - Parsed parameters object
* @returns Parameters with flattened array wrappers
*/
private flattenArrayWrappers(parameters: Record<string, unknown>): Record<string, unknown> {
const flattened: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parameters)) {
if (this.isArrayWrapperCandidate(key, value)) {
// This is an array wrapper - flatten it
const wrapperObj = value as Record<string, unknown>;
const innerKeys = Object.keys(wrapperObj);
if (innerKeys.length === 1) {
const innerKey = innerKeys[0];
const innerValue = wrapperObj[innerKey];
// Check if inner key is singular form of outer key
if (this.isSingularOf(innerKey, key)) {
// Flatten: {notes: {note: [...]}} → {notes: [...]}
flattened[key] = innerValue;
continue;
}
}
}
// No flattening needed, keep as is
flattened[key] = value;
}
return flattened;
}
/**
* Check if a value is a candidate for array wrapper flattening
* @param key - Parameter key (e.g., "notes")
* @param value - Parameter value to check
* @returns True if this looks like an array wrapper pattern
*/
private isArrayWrapperCandidate(key: string, value: unknown): boolean {
// Must be an object (not array, not primitive)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
const innerKeys = Object.keys(obj);
// Must have exactly one property
if (innerKeys.length !== 1) {
return false;
}
const innerKey = innerKeys[0];
const innerValue = obj[innerKey];
// Inner value should be an array or could become an array
// (single items are often converted to arrays by the parser)
return this.isSingularOf(innerKey, key) &&
(Array.isArray(innerValue) || typeof innerValue === 'object');
}
/**
* Check if one word is the singular form of another (simple heuristic)
* @param singular - Potential singular form (e.g., "note")
* @param plural - Potential plural form (e.g., "notes")
* @returns True if singular appears to be singular form of plural
*/
private isSingularOf(singular: string, plural: string): boolean {
// Simple heuristics for common English pluralization
if (plural === singular + 's') return true; // note → notes
if (plural === singular + 'es') return true; // box → boxes
if (plural.endsWith('ies') && singular.endsWith('y')) { // entry → entries
return plural === singular.slice(0, -1) + 'ies';
}
// Add more rules as needed for your specific use cases
return false;
}
/**
* Parse a single XML element value, handling different data types and structures
* @param element - XML element to parse
* @returns Parsed value (string, number, boolean, object, or array)
*/
private parseXMLValue(element: Element): unknown {
// If element has children, parse as object
if (element.children.length > 0) {
return this.parseXMLParameters(element);
}
// Get text content
const textContent = element.textContent?.trim() || '';
// Try to parse as number
if (/^-?\d+(\.\d+)?$/.test(textContent)) {
return parseFloat(textContent);
}
// Try to parse as boolean
if (textContent === 'true') return true;
if (textContent === 'false') return false;
// Return as string
return textContent;
}
/**
* Synchronize UI state after successful tool execution
* Uses the centralized refresh method from the project store
*/
private syncUIState(): void {
try {
// Use the centralized refresh method from the store
const storeActions = useProjectStore.getState();
if (storeActions.refreshProjectState) {
storeActions.refreshProjectState();
}
} catch (error) {
console.warn('Failed to sync UI state after XML tool execution:', error);
// Don't throw - UI sync failure shouldn't break tool execution
}
}
}
-239
View File
@@ -1,239 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { LLM_PROTOCOL } from '../../constants/llmConstants';
/**
* Claude (via OpenRouter) provider using the OpenAI-compatible Chat Completions API.
* Difference from the generic OpenAI provider: message content is an array of parts
* with a single text item per message (future-ready for images, tools, etc.).
*/
export class ClaudeOpenRouterProvider extends LLMProvider {
readonly name = 'Claude (OpenRouter)';
private isOllamaFormat: boolean | null = null; // Detected at runtime
constructor() {
super();
}
/**
* Build OpenAI-compatible messages array where each message content is an array of parts.
*/
private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }> {
type ORPart = { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } };
const openAIMessages: Array<{ role: string; content: Array<ORPart> }> = [];
// Add system prompt if provided
if (systemPrompt) {
openAIMessages.push({ role: 'system', content: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }] });
}
// Add conversation history with preserved roles
let lastUserIndex = -1;
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === 'user') lastUserIndex = i;
}
openAIMessages.push(
...messages.map((msg, i) => {
const part: ORPart = { type: 'text', text: msg.content };
if (msg.role === 'user' && i === lastUserIndex) {
part.cache_control = { type: 'ephemeral' };
}
return {
role: msg.role,
content: [part]
};
})
);
return openAIMessages;
}
/**
* Create API request with proper headers and body
*/
private async createApiRequest(messages: Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }>, config: ReturnType<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
const headers: Record<string, string> = {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
};
// Optional, but recommended by OpenRouter docs to set referer/title for attribution
// if (typeof window !== 'undefined') {
// headers['HTTP-Referer'] = window.location.origin;
// headers['X-Title'] = 'K.G.Studio';
// }
const response = await fetch(config.apiEndpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: config.model,
messages,
stream: streaming,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`);
}
return response;
}
/**
* Get current configuration values from ConfigManager
*/
private getCurrentConfig() {
const configManager = ConfigManager.instance();
const apiKey = configManager.get('general.claude_openrouter.api_key') as string;
const model = configManager.get('general.claude_openrouter.model') as string;
const baseURL = configManager.get('general.claude_openrouter.base_url') as string;
// baseURL is the full API endpoint for OpenRouter (e.g., https://openrouter.ai/api/v1/chat/completions)
const apiEndpoint = baseURL;
return { apiKey, model, baseURL, apiEndpoint };
}
/**
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
*/
private detectStreamFormat(firstChunk: string): boolean {
// If it starts with "data: ", it's OpenAI SSE format
if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return false; // Not Ollama format
}
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
try {
const json = JSON.parse(firstChunk.trim());
return typeof json.done === 'boolean';
} catch {
return false; // Not valid JSON, assume OpenAI format
}
}
/**
* Parse Ollama's raw JSON chunk format
*/
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
const thinking: string | undefined = json.message?.thinking;
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
return {
thinking,
content,
isDone: json.done === true
};
} catch {
return {}; // Invalid JSON, return empty object
}
}
/**
* Parse OpenAI's SSE format chunk
*/
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } {
if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return {};
}
const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length);
if (data === LLM_PROTOCOL.SSE_DONE_MARKER) {
return { isDone: true };
}
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta;
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
const content: string | undefined = delta?.content;
return { thinking, content, isDone: false };
} catch {
return {}; // Skip invalid JSON lines
}
}
private async *processContentChunk(
thinking: string | undefined,
content: string | undefined,
lastSegmentType: { current: 'thinking' | 'content' | null }
): AsyncIterableIterator<StreamChunk> {
if (typeof thinking === 'string' && thinking.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'thinking') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: thinking };
lastSegmentType.current = 'thinking';
}
if (typeof content === 'string' && content.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'content') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: content };
lastSegmentType.current = 'content';
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string
): AsyncIterableIterator<StreamChunk> {
const config = this.getCurrentConfig();
const requestMessages = this.buildRequestMessages(messages, systemPrompt);
const response = await this.createApiRequest(requestMessages, config, true);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`${this.name} streaming: Failed to get response reader from API response`);
}
const decoder = new TextDecoder();
let buffer = '';
let firstChunkProcessed = false;
const lastSegmentType = { current: null as 'thinking' | 'content' | null };
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split by newlines for SSE (and also works for line-delimited JSON)
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Detect format on first non-empty chunk
if (!firstChunkProcessed) {
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
firstChunkProcessed = true;
}
const { thinking, content, isDone } = this.isOllamaFormat
? this.parseOllamaChunk(trimmedLine)
: this.parseOpenAIChunk(trimmedLine);
if (isDone) {
yield { type: 'done', content: '' };
return;
}
yield* this.processContentChunk(thinking, content, lastSegmentType);
}
}
} finally {
reader.releaseLock();
}
}
}
-162
View File
@@ -1,162 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
/**
* Anthropic Claude API provider implementation
*/
export class ClaudeProvider extends LLMProvider {
readonly name = 'Claude';
private apiKey: string;
private model: string;
private baseURL: string = 'https://api.anthropic.com';
private apiEndpoint: string;
constructor() {
super();
const configManager = ConfigManager.instance();
this.apiKey = configManager.get('general.claude.api_key') as string;
this.model = configManager.get('general.claude.model') as string;
this.apiEndpoint = `${this.baseURL}/v1/messages`;
}
/**
* Convert internal messages to Claude's format
*/
private convertMessages(messages: Message[], systemPrompt?: string): {
system?: string;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
} {
const claudeMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
claudeMessages.push(...messages.map(msg => ({ role: msg.role, content: msg.content })));
return {
system: systemPrompt,
messages: claudeMessages
};
}
/**
* Parse Claude's streaming response chunks
*/
private parseClaudeStreamChunk(line: string): { content?: string; isDone?: boolean } {
if (!line.startsWith('data: ')) {
return {};
}
const data = line.slice(6);
if (data === '[DONE]') {
return { isDone: true };
}
try {
const json = JSON.parse(data);
// Handle different Claude streaming event types
switch (json.type) {
case 'content_block_delta':
return {
content: json.delta?.text,
isDone: false
};
case 'message_stop':
return { isDone: true };
default:
return {};
}
} catch {
return {}; // Skip invalid JSON lines
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: Record<string, unknown>[]
): AsyncIterableIterator<StreamChunk> {
const { system, messages: claudeMessages } = this.convertMessages(messages, systemPrompt);
const requestBody: {
model: string;
max_tokens: number;
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
stream: boolean;
system?: string;
tools?: Record<string, unknown>[];
} = {
model: this.model,
max_tokens: 8192,
messages: claudeMessages,
stream: true
};
if (system) {
requestBody.system = system;
}
if (tools && tools.length > 0) {
requestBody.tools = tools;
}
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'x-api-key': this.apiKey,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(requestBody),
mode: 'cors'
});
if (!response.ok) {
throw new Error(`Claude API error: ${response.status} ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const parseResult = this.parseClaudeStreamChunk(trimmedLine);
if (parseResult.isDone) {
yield { type: 'done', content: '' };
return;
}
if (parseResult.content) {
yield {
type: 'text',
content: parseResult.content
};
}
}
}
} finally {
reader.releaseLock();
}
}
}
-183
View File
@@ -1,183 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
/**
* Google Gemini API provider implementation
*/
export class GeminiProvider extends LLMProvider {
readonly name = 'Gemini';
private apiKey: string;
private model: string;
private baseURL: string = 'https://generativelanguage.googleapis.com';
private apiEndpoint: string;
constructor() {
super();
const configManager = ConfigManager.instance();
this.apiKey = configManager.get('general.gemini.api_key') as string;
this.model = configManager.get('general.gemini.model') as string;
this.apiEndpoint = `${this.baseURL}/v1beta/models/${this.model}`;
}
/**
* Convert internal messages to Gemini's format
*/
private convertMessages(messages: Message[], systemPrompt?: string): {
systemInstruction?: { parts: Array<{ text: string }> };
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
} {
const geminiMessages: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = [];
for (const msg of messages) {
let role: 'user' | 'model';
if (msg.role === 'assistant') {
role = 'model';
} else {
// Treat system and user messages as 'user' role
role = 'user';
}
geminiMessages.push({
role,
parts: [{ text: msg.content }]
});
}
const result: {
systemInstruction?: { parts: Array<{ text: string }> };
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
} = {
contents: geminiMessages
};
if (systemPrompt) {
result.systemInstruction = {
parts: [{ text: systemPrompt }]
};
}
return result;
}
/**
* Parse Gemini's streaming response chunks
*/
private parseGeminiStreamChunk(chunk: string): { content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
// Gemini streaming format
if (json.candidates && json.candidates.length > 0) {
const candidate = json.candidates[0];
// Check if generation is finished
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
return { isDone: true };
}
// Extract text content
const content = candidate.content?.parts?.[0]?.text;
if (content) {
return { content, isDone: false };
}
}
// Check for explicit done signal
if (json.done === true) {
return { isDone: true };
}
return {};
} catch {
return {}; // Skip invalid JSON
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: Record<string, unknown>[]
): AsyncIterableIterator<StreamChunk> {
const { systemInstruction, contents } = this.convertMessages(messages, systemPrompt);
const requestBody: {
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
generationConfig: { temperature: number; maxOutputTokens: number };
systemInstruction?: { parts: Array<{ text: string }> };
tools?: Record<string, unknown>[];
} = {
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 8192,
}
};
if (systemInstruction) {
requestBody.systemInstruction = systemInstruction;
}
if (tools && tools.length > 0) {
requestBody.tools = tools;
}
const response = await fetch(`${this.apiEndpoint}:streamGenerateContent?key=${this.apiKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get response reader');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Gemini sends JSON objects separated by newlines
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const parseResult = this.parseGeminiStreamChunk(trimmedLine);
if (parseResult.isDone) {
yield { type: 'done', content: '' };
return;
}
if (parseResult.content) {
yield {
type: 'text',
content: parseResult.content
};
}
}
}
} finally {
reader.releaseLock();
}
}
}
+139 -14
View File
@@ -1,21 +1,146 @@
import OpenAI from 'openai';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import type { Message, ToolCall } from '../core/AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
/**
* Abstract interface for LLM providers
* LLM provider using the OpenAI SDK.
* Works with any OpenAI-compatible API (OpenAI, OpenRouter, Ollama, vLLM, etc.)
*/
export abstract class LLMProvider {
abstract name: string;
export class LLMProvider {
private client: OpenAI;
private model: string;
constructor(apiKey: string, model: string, baseURL?: string) {
// The OpenAI SDK appends /chat/completions itself, so strip it if the user included it
const normalizedBaseURL = baseURL?.replace(/\/chat\/completions\/?$/, '') || undefined;
this.client = new OpenAI({
apiKey,
...(normalizedBaseURL ? { baseURL: normalizedBaseURL } : {}),
dangerouslyAllowBrowser: true,
});
this.model = model;
}
/**
* Generate a streaming response from the LLM
* @param messages The full conversation history with preserved roles
* @param systemPrompt The system prompt (optional, can be included in messages)
* @param tools Available tools (optional for now)
* Convert internal Message[] to OpenAI ChatCompletionMessageParam[]
*/
abstract generateStream(
messages: Message[],
private convertMessages(
messages: Message[],
systemPrompt?: string
): OpenAI.ChatCompletionMessageParam[] {
const result: OpenAI.ChatCompletionMessageParam[] = [];
if (systemPrompt) {
result.push({ role: 'system', content: systemPrompt });
}
for (const msg of messages) {
if (msg.role === 'user') {
result.push({ role: 'user', content: msg.content ?? '' });
} else if (msg.role === 'assistant') {
const assistantMsg: OpenAI.ChatCompletionAssistantMessageParam = {
role: 'assistant',
content: msg.content ?? null,
};
if (msg.tool_calls && msg.tool_calls.length > 0) {
assistantMsg.tool_calls = msg.tool_calls.map(tc => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.function.name, arguments: tc.function.arguments },
}));
}
result.push(assistantMsg);
} else if (msg.role === 'tool') {
result.push({
role: 'tool',
tool_call_id: msg.tool_call_id!,
content: msg.content ?? '',
});
}
}
return result;
}
/**
* Generate a streaming response from the LLM.
* Yields StreamChunks for text content and tool calls.
*/
async *generateStream(
messages: Message[],
systemPrompt?: string,
tools?: Record<string, unknown>[]
): AsyncIterableIterator<StreamChunk>;
}
tools?: OpenAIToolDefinition[],
): AsyncIterableIterator<StreamChunk> {
const openaiMessages = this.convertMessages(messages, systemPrompt);
const requestParams: OpenAI.ChatCompletionCreateParamsStreaming = {
model: this.model,
messages: openaiMessages,
stream: true,
};
if (tools && tools.length > 0) {
requestParams.tools = tools as unknown as OpenAI.ChatCompletionTool[];
requestParams.tool_choice = 'auto';
}
const stream = this.client.chat.completions.stream(requestParams);
// Accumulate tool calls across chunks (they arrive incrementally)
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>();
for await (const chunk of stream) {
console.log('LLMProvider: chunk', JSON.stringify(chunk));
const choice = chunk.choices[0];
if (!choice) continue;
const delta = choice.delta;
// Yield text content
if (delta.content) {
yield { type: 'text', content: delta.content };
}
// Accumulate tool calls from deltas
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCallAccumulator.get(tc.index);
if (existing) {
// Append to existing tool call
if (tc.function?.arguments) {
existing.arguments += tc.function.arguments;
}
} else {
// New tool call
toolCallAccumulator.set(tc.index, {
id: tc.id ?? '',
name: tc.function?.name ?? '',
arguments: tc.function?.arguments ?? '',
});
}
}
}
}
// After stream ends, get the final completion for finish_reason
const finalCompletion = await stream.finalChatCompletion();
const finishReason = finalCompletion.choices[0]?.finish_reason ?? 'stop';
// Emit accumulated tool calls
if (toolCallAccumulator.size > 0) {
for (const [, tc] of toolCallAccumulator) {
const toolCall: ToolCall = {
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
};
yield { type: 'tool_call', content: '', toolCall };
}
}
// Signal completion
yield { type: 'done', content: '', finishReason };
}
}
-237
View File
@@ -1,237 +0,0 @@
import { LLMProvider } from './LLMProvider';
import type { StreamChunk } from './StreamingTypes';
import type { Message } from '../core/AgentState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { URL_CONSTANTS } from '../../constants/coreConstants';
import { LLM_PROTOCOL } from '../../constants/llmConstants';
/**
* OpenAI API provider implementation
*/
export class OpenAIProvider extends LLMProvider {
readonly name = 'OpenAI';
private isOllamaFormat: boolean | null = null; // Detected at runtime
constructor() {
super();
}
/**
* Build OpenAI-compatible messages array from input messages and system prompt
*/
private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: string }> {
const openAIMessages: Array<{ role: string; content: string }> = [];
// Add system prompt if provided
if (systemPrompt) {
openAIMessages.push({ role: 'system', content: systemPrompt });
}
// Add conversation history with preserved roles
openAIMessages.push(...messages.map(msg => ({
role: msg.role,
content: msg.content
})));
return openAIMessages;
}
/**
* Create API request with proper headers and body
*/
private async createApiRequest(messages: Array<{ role: string; content: string }>, config: ReturnType<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
const response = await fetch(config.apiEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: config.model,
...(config.flexMode && !config.isCompatibleProvider ? { service_tier: 'flex' } : {}),
messages,
stream: streaming,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`);
}
return response;
}
/**
* Process thinking and content chunks, yielding appropriate StreamChunks
*/
private async *processContentChunk(
thinking: string | undefined,
content: string | undefined,
lastSegmentType: { current: 'thinking' | 'content' | null }
): AsyncIterableIterator<StreamChunk> {
if (typeof thinking === 'string' && thinking.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'thinking') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: thinking };
lastSegmentType.current = 'thinking';
}
if (typeof content === 'string' && content.length > 0) {
if (lastSegmentType.current && lastSegmentType.current !== 'content') {
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
}
yield { type: 'text', content: content };
lastSegmentType.current = 'content';
}
}
/**
* Get current configuration values from ConfigManager
*/
private getCurrentConfig() {
const configManager = ConfigManager.instance();
const llmProvider = configManager.get('general.llm_provider') as string;
const isCompatibleProvider = llmProvider === 'openai_compatible';
if (isCompatibleProvider) {
const apiKey = configManager.get('general.openai_compatible.api_key') as string;
const model = configManager.get('general.openai_compatible.model') as string;
const baseURL = configManager.get('general.openai_compatible.base_url') as string;
// For compatible providers, use the full URL as provided (assume it includes the endpoint)
// Common patterns: http://localhost:11434/api/chat (Ollama), https://api.openrouter.ai/v1 (OpenRouter)
const apiEndpoint = baseURL;
const flexMode = false; // Not applicable to compatible providers
return { apiKey, model, baseURL, apiEndpoint, flexMode, isCompatibleProvider };
} else {
const apiKey = configManager.get('general.openai.api_key') as string;
const model = configManager.get('general.openai.model') as string;
const flexMode = (configManager.get('general.openai.flex') as boolean) === true;
const baseURL = URL_CONSTANTS.DEFAULT_OPENAI_BASE_URL;
const apiEndpoint = `${baseURL}/chat/completions`;
return { apiKey, model, baseURL, apiEndpoint, flexMode, isCompatibleProvider };
}
}
/**
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
*/
private detectStreamFormat(firstChunk: string): boolean {
// If it starts with "data: ", it's OpenAI SSE format
if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return false; // Not Ollama format
}
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
try {
const json = JSON.parse(firstChunk.trim());
return typeof json.done === 'boolean';
} catch {
return false; // Not valid JSON, assume OpenAI format
}
}
/**
* Parse Ollama's raw JSON chunk format
*/
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } {
try {
const json = JSON.parse(chunk.trim());
const thinking: string | undefined = json.message?.thinking;
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
return {
thinking,
content,
isDone: json.done === true
};
} catch {
return {}; // Invalid JSON, return empty object
}
}
/**
* Parse OpenAI's SSE format chunk
*/
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } {
if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
return {};
}
const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length);
if (data === LLM_PROTOCOL.SSE_DONE_MARKER) {
return { isDone: true };
}
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta;
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
const content: string | undefined = delta?.content;
return { thinking, content, isDone: false };
} catch {
return {}; // Skip invalid JSON lines
}
}
async *generateStream(
messages: Message[],
systemPrompt?: string
): AsyncIterableIterator<StreamChunk> {
const config = this.getCurrentConfig();
const requestMessages = this.buildRequestMessages(messages, systemPrompt);
const response = await this.createApiRequest(requestMessages, config, true);
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`${this.name} streaming: Failed to get response reader from API response`);
}
const decoder = new TextDecoder();
let buffer = '';
let firstChunkProcessed = false;
const lastSegmentType = { current: null as 'thinking' | 'content' | null };
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// For Ollama format, we need to split by newlines for JSON objects
// For OpenAI format, we also split by newlines for SSE
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Detect format on first non-empty chunk
if (!firstChunkProcessed) {
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
firstChunkProcessed = true;
}
const { thinking, content, isDone } = this.isOllamaFormat
? this.parseOllamaChunk(trimmedLine)
: this.parseOpenAIChunk(trimmedLine);
if (isDone) {
yield { type: 'done', content: '' };
return;
}
yield* this.processContentChunk(thinking, content, lastSegmentType);
}
}
} finally {
reader.releaseLock();
}
}
}
+5 -19
View File
@@ -1,27 +1,13 @@
/**
* Types for streaming LLM responses and tool execution
* Types for streaming LLM responses
*/
import type { ToolResult } from '../tools/BaseTool';
// Re-export for convenience
export type { ToolResult };
export interface ToolInvocation {
id: string;
name: string;
parameters: Record<string, unknown>;
}
import type { ToolCall } from '../core/AgentState';
export interface StreamChunk {
type: 'text' | 'tool_call' | 'tool_result' | 'done';
content: string;
toolCall?: ToolInvocation;
toolResult?: ToolResult;
toolCall?: ToolCall;
toolResult?: { name: string; success: boolean; result: string };
finishReason?: string; // 'stop' | 'tool_calls' — present on 'done' chunks
}
export interface LLMResponse {
content: string;
toolCalls?: ToolInvocation[];
finished: boolean;
}
+9 -9
View File
@@ -12,35 +12,35 @@ import { KGCore } from '../../core/KGCore';
*/
export class AddNotesTool extends BaseTool {
readonly name = 'add_notes';
readonly description = 'Create one or more MIDI notes in the current region. Each note requires pitch (e.g., "C4", "F#3"), start_beat (beat position), and length (duration in beats).';
readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.';
readonly parameters: Record<string, ToolParameter> = {
notes: {
type: 'array',
description: 'Array of notes to create',
description: 'List of notes to add. To create a chord, give multiple notes the same start_beat. To create a melody, use sequential start_beat values.',
required: true,
items: {
type: 'object',
description: 'A MIDI note definition',
description: 'A single note',
properties: {
pitch: {
type: 'string',
description: 'Note pitch in scientific notation (e.g., "C4", "F#3", "Bb2")',
description: 'Pitch in scientific notation: note name, optional accidental (# or b), and octave number. Examples: "C4" (middle C), "F#3" (F-sharp 3rd octave), "Bb2" (B-flat 2nd octave).',
required: true
},
start_beat: {
type: 'number',
description: 'Start position in beats (e.g., 0, 1.5, 2)',
description: 'Absolute beat position on the project timeline where the note starts. This is NOT relative to the region — beat 6 means beat 6 in the project regardless of where the region begins. Fractional values are supported (e.g., 0.5 = half a beat after beat 0).',
required: true
},
length: {
type: 'number',
description: 'Note duration in beats (e.g., 1, 0.5, 4)',
description: 'Duration of the note in beats. In 4/4 time: 4 = whole note, 2 = half note, 1 = quarter note, 0.5 = eighth note, 0.25 = sixteenth note.',
required: true
},
velocity: {
type: 'number',
description: 'Note velocity (1-127, default: 127)',
description: 'Note velocity / loudness from 1 (softest) to 127 (loudest). Defaults to 127 if omitted.',
required: false
}
}
@@ -48,7 +48,7 @@ export class AddNotesTool extends BaseTool {
},
region_id: {
type: 'string',
description: 'ID of the region to add notes to. If not provided, uses the currently selected region.',
description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
required: false
}
};
-49
View File
@@ -1,49 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
import { AgentCore } from '../core/AgentCore';
/**
* Tool for signaling task completion
* This is a pure agent state tool that doesn't modify the DAW but signals
* to the agent system that the user's requested task has been completed
*/
export class AttemptCompletionTool extends BaseTool {
readonly name = 'attempt_completion';
readonly description = 'Signal that the current user task is fully complete. Only use this when you have successfully fulfilled all aspects of the user\'s request.';
readonly parameters: Record<string, ToolParameter> = {
comment: {
type: 'string',
description: 'A brief comment describing what was completed and any relevant details about the task fulfillment.',
required: true
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Validate parameters
this.validateParameters(params);
const comment = params.comment as string;
// Validate comment is not empty
if (!comment.trim()) {
return this.createErrorResult('Comment cannot be empty. Please provide a meaningful completion summary.');
}
// Get current agent state and update task completion status
const agentCore = AgentCore.instance();
const agentState = agentCore.getAgentState();
// Mark that we're no longer working on a task
agentState.setIsWorkingOnTask(false);
return this.createSuccessResult(
`Task completed: ${comment}. `
);
} catch (error) {
return this.createErrorResult(`Failed to mark task as complete: ${error}`);
}
}
}
+83 -5
View File
@@ -21,7 +21,28 @@ export interface ToolParameter {
}
/**
* Tool definition schema
* OpenAI-compatible JSON Schema for function parameters
*/
export interface OpenAIFunctionParameters {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
}
/**
* OpenAI-compatible tool definition
*/
export interface OpenAIToolDefinition {
type: 'function';
function: {
name: string;
description: string;
parameters: OpenAIFunctionParameters;
};
}
/**
* Tool definition schema (internal format)
*/
export interface ToolDefinition {
name: string;
@@ -48,14 +69,71 @@ export abstract class BaseTool {
/**
* Get the tool definition in OpenAI function calling format
*/
getDefinition(): ToolDefinition {
getDefinition(): OpenAIToolDefinition {
return {
name: this.name,
description: this.description,
parameters: this.parameters
type: 'function',
function: {
name: this.name,
description: this.description,
parameters: this.convertToJsonSchema(this.parameters)
}
};
}
/**
* Convert internal ToolParameter map to OpenAI-compatible JSON Schema
*/
private convertToJsonSchema(params: Record<string, ToolParameter>): OpenAIFunctionParameters {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [name, param] of Object.entries(params)) {
properties[name] = this.convertParamToJsonSchema(param);
if (param.required) {
required.push(name);
}
}
return {
type: 'object',
properties,
...(required.length > 0 ? { required } : {})
};
}
/**
* Convert a single ToolParameter to JSON Schema format
*/
private convertParamToJsonSchema(param: ToolParameter): Record<string, unknown> {
const schema: Record<string, unknown> = {
type: param.type,
description: param.description
};
if (param.type === 'array' && param.items) {
schema.items = this.convertParamToJsonSchema(param.items);
}
if (param.type === 'object' && param.properties) {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [name, prop] of Object.entries(param.properties)) {
properties[name] = this.convertParamToJsonSchema(prop);
if (prop.required) {
required.push(name);
}
}
schema.properties = properties;
if (required.length > 0) {
schema.required = required;
}
}
return schema;
}
/**
* Validate parameters against the tool's parameter schema
* @param params Parameters to validate
+5 -5
View File
@@ -12,22 +12,22 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
*/
export class ReadMusicTool extends BaseTool {
readonly name = 'read_music';
readonly description = 'Read the music content from a specific track or all tracks, returning the content in ABC notation format.';
readonly description = 'Read existing musical content from one or more tracks, returned as ABC notation. Use this to understand what notes already exist before making edits. Always call this before asking the user about their music. The output is bar-aligned and includes key/time signature headers.';
readonly parameters: Record<string, ToolParameter> = {
track_id: {
type: 'string',
description: 'The track ID to read, or "all" to read all tracks. If not provided, reads the first available track.',
description: 'Which track to read. Pass a specific track ID, or "all" to read every track. If omitted, reads the first available track.',
required: false
},
start_beat: {
type: 'number',
description: 'Start beat position to read from (default: 0)',
description: 'Absolute beat position to start reading from. The actual output will be rounded down to the nearest bar boundary. Defaults to 0.',
required: false
},
length: {
type: 'number',
description: 'Length in beats to read (default: entire track/project)',
description: 'Number of beats to read. The actual output will be rounded up to the nearest bar boundary. If omitted, reads to the end of the track.',
required: false
}
};
+5 -5
View File
@@ -11,22 +11,22 @@ import { KGCore } from '../../core/KGCore';
*/
export class RemoveNotesTool extends BaseTool {
readonly name = 'remove_notes';
readonly description = 'Remove MIDI notes from the current region within a specified beat range. All notes that start within the range will be deleted.';
readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.';
readonly parameters: Record<string, ToolParameter> = {
start_beat: {
type: 'number',
description: 'Start of the beat range to remove notes from (inclusive)',
description: 'Absolute beat position where the removal range begins (inclusive). A note starting at exactly this beat will be removed.',
required: true
},
end_beat: {
type: 'number',
description: 'End of the beat range to remove notes from (exclusive)',
description: 'Absolute beat position where the removal range ends (exclusive). A note starting at exactly this beat will NOT be removed. Must be greater than start_beat.',
required: true
},
region_id: {
type: 'string',
description: 'ID of the region to remove notes from. If not provided, uses the currently selected region.',
description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
required: false
}
};
-35
View File
@@ -1,35 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
/**
* Pseudo tool for handling <think> tags in LLM responses
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
* Handles XML format: <think>any content here</think>
* This is functionally identical to ThinkingTool but handles the shorter tag name
*/
export class ThinkTool extends BaseTool {
readonly name = 'think';
readonly description = 'Pseudo tool for handling LLM thinking content from <think> tags. Shows content in UI but does not send results back to LLM.';
readonly parameters: Record<string, ToolParameter> = {
content: {
type: 'string',
description: 'The thinking content from the XML tag',
required: false
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Extract the thinking content from the parameters
// const content = params.content as string || '';
// Return the thinking content as a successful result
// This will be displayed in the UI but not sent back to the LLM
return this.createSuccessResult("Thinking completed.");
} catch (error) {
return this.createErrorResult(`Failed to process thinking content: ${error}`);
}
}
}
-34
View File
@@ -1,34 +0,0 @@
import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
/**
* Pseudo tool for handling <thinking> tags in LLM responses
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
* Handles XML format: <thinking>any content here</thinking>
*/
export class ThinkingTool extends BaseTool {
readonly name = 'thinking';
readonly description = 'Pseudo tool for handling LLM thinking content. Shows content in UI but does not send results back to LLM.';
readonly parameters: Record<string, ToolParameter> = {
content: {
type: 'string',
description: 'The thinking content from the XML tag',
required: false
}
};
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Extract the thinking content from the parameters
// const content = params.content as string || '';
// Return the thinking content as a successful result
// This will be displayed in the UI but not sent back to the LLM
return this.createSuccessResult("Thinking completed.");
} catch (error) {
return this.createErrorResult(`Failed to process thinking content: ${error}`);
}
}
}
+3 -9
View File
@@ -1,25 +1,19 @@
// Base tool system
export { BaseTool } from './BaseTool';
export type { ToolResult, ToolParameter, ToolDefinition } from './BaseTool';
export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool';
// Specific tools
import { AddNotesTool } from './AddNotesTool';
import { RemoveNotesTool } from './RemoveNotesTool';
import { ReadMusicTool } from './ReadMusicTool';
import { AttemptCompletionTool } from './AttemptCompletionTool';
import { ThinkingTool } from './ThinkingTool';
import { ThinkTool } from './ThinkTool';
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, AttemptCompletionTool, ThinkingTool, ThinkTool };
export { AddNotesTool, RemoveNotesTool, ReadMusicTool };
// Tool registry for easy access
export const AVAILABLE_TOOLS = {
add_notes: AddNotesTool,
remove_notes: RemoveNotesTool,
read_music: ReadMusicTool,
attempt_completion: AttemptCompletionTool,
thinking: ThinkingTool,
think: ThinkTool
} as const;
export type ToolName = keyof typeof AVAILABLE_TOOLS;
export type ToolName = keyof typeof AVAILABLE_TOOLS;
+62 -174
View File
@@ -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);
+11 -131
View File
@@ -1,39 +1,8 @@
import React, { memo, useState } from 'react';
import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { extractXMLFromString } from '../../util/xmlUtil';
interface ToolXMLExpanderProps {
toolName: string;
xmlContent: string;
}
const ToolXMLExpander: React.FC<ToolXMLExpanderProps> = ({ toolName, xmlContent }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="tool-xml-expander">
<div
className="tool-xml-expander-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="tool-xml-expander-arrow">
{isExpanded ? '▼' : '▶'}
</span>
<span className="tool-xml-expander-title">
🔧 Tool: {toolName}
</span>
</div>
{isExpanded && (
<div className="tool-xml-expander-content">
{xmlContent}
</div>
)}
</div>
);
};
interface AssistantMessageProps {
content: string;
@@ -62,95 +31,34 @@ const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
});
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort }) => {
// Function to process content and replace XML blocks with expanders
const processContentWithXMLExpanders = (text: string) => {
const xmlBlocks = extractXMLFromString(text);
if (xmlBlocks.length === 0) {
// No XML blocks found, return content as-is
return text;
}
let processedContent = text;
const expanders: React.ReactElement[] = [];
let expanderIndex = 0;
// Replace each XML block with a placeholder
xmlBlocks.forEach((xmlBlock) => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
const placeholder = `__XML_EXPANDER_${expanderIndex}__`;
processedContent = processedContent.replace(xmlBlock, placeholder);
expanders[expanderIndex] = (
<ToolXMLExpander
key={`xml-expander-${expanderIndex}`}
toolName={toolName}
xmlContent={xmlBlock}
/>
);
expanderIndex++;
});
// Split content by placeholders and interleave with expanders
const parts = processedContent.split(/__XML_EXPANDER_\d+__/);
const result: (string | React.ReactElement)[] = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
result.push(parts[i]);
}
if (i < expanders.length) {
result.push(expanders[i]);
}
}
return result;
};
// Handle special abort link for streaming messages
const renderContent = () => {
// Handle special abort link for streaming messages
if (isStreaming && onAbort && content.includes('click here to abort')) {
// Check if content has the processing wave HTML
const hasProcessingWave = content.includes('<span class="processing-wave">Processing...</span>');
if (hasProcessingWave) {
// Parse the content to handle both the wave animation and abort link
const parts = content.split('click here to abort');
const beforeAbort = parts[0];
const afterAbort = parts[1];
// Replace the HTML span with JSX
const processedBefore = beforeAbort.replace(
const beforeAbort = parts[0].replace(
'<span class="processing-wave">Processing...</span>',
''
);
return (
<span>
<span className="processing-wave">Processing...</span>
{processedBefore}
<button
onClick={onAbort}
className="abort-link"
>
{beforeAbort}
<button onClick={onAbort} className="abort-link">
click here to abort
</button>
{afterAbort}
{parts[1]}
</span>
);
} else {
// Original logic for non-wave processing messages
const parts = content.split('click here to abort');
return (
<span>
{parts[0]}
<button
onClick={onAbort}
className="abort-link"
>
<button onClick={onAbort} className="abort-link">
click here to abort
</button>
{parts[1]}
@@ -159,34 +67,6 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
}
}
const processedContent = processContentWithXMLExpanders(content);
// If we have mixed content (text + React elements), render them separately
if (Array.isArray(processedContent)) {
return (
<div>
{processedContent.map((item, index) => {
if (typeof item === 'string') {
return (
<ReactMarkdown
key={`text-${index}`}
remarkPlugins={[remarkGfm]}
components={{
code: CodeComponent,
}}
>
{item}
</ReactMarkdown>
);
} else {
return item; // React element (expander)
}
})}
</div>
);
}
// Plain text content, render with markdown
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -194,7 +74,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
code: CodeComponent,
}}
>
{processedContent as string}
{content}
</ReactMarkdown>
);
};
@@ -208,4 +88,4 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
);
};
export default memo(AssistantMessage);
export default memo(AssistantMessage);
@@ -368,7 +368,7 @@ const GeneralSettings: React.FC = () => {
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
placeholder="e.g. https://openrouter.ai/api/v1"
value={claudeOpenRouterBaseUrl}
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/>
@@ -424,7 +424,7 @@ const GeneralSettings: React.FC = () => {
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
placeholder="e.g. https://openrouter.ai/api/v1"
value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/>
+67 -125
View File
@@ -7,9 +7,8 @@ import { KGCore } from './KGCore';
import { KGMidiRegion } from './region/KGMidiRegion';
import { convertRegionToABCNotation } from '../util/abcNotationUtil';
import { extractXMLFromString } from '../util/xmlUtil';
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
import { AgentCore } from '../agent/core/AgentCore';
import { AttemptCompletionTool } from '../agent/tools/AttemptCompletionTool';
import { AVAILABLE_TOOLS } from '../agent/tools';
import type { TimeSignature } from '../types/projectTypes';
import { useProjectStore } from '../stores/projectStore';
@@ -28,8 +27,7 @@ export class KGDebugger {
'debugSelectedItems()',
'createTestRegion()',
'testExtractXMLFromString(input)',
'testXMLToolExecution(input)',
'testAttemptCompletion(comment)',
'testToolCall(jsonInput)',
'inputChatBox(content, interval?)'
]);
}
@@ -264,129 +262,72 @@ export class KGDebugger {
}
/**
* Test the complete XML tool execution pipeline
* @param input - String containing XML tool invocations to execute
* Test native tool calling by executing tool calls from a JSON string.
* Accepts a single tool call object or an array of tool call objects.
*
* Usage examples in browser console:
*
* // Single tool call:
* await KGStudio.KGDebugger.testToolCall('{"name":"read_music","arguments":{"start_beat":0,"length":8}}')
*
* // Multiple tool calls:
* await KGStudio.KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start_beat":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start_beat":0,"length":1}]}}]')
*
* // Can also pass a JS object directly (no need to stringify):
* await KGStudio.KGDebugger.testToolCall({name:"read_music",arguments:{start_beat:0}})
*
* @param input - JSON string, object, or array of tool call(s).
* Each tool call should have: { name: string, arguments: object }
*/
public async testXMLToolExecution(input: string): Promise<void> {
console.log('------------ ASSISTANT ------------');
console.log(input);
console.log('-----------------------------------');
public async testToolCall(input: string | Record<string, unknown> | Record<string, unknown>[]): Promise<void> {
try {
// Extract XML blocks first to get tool names (same logic as ChatBox)
const xmlBlocks = extractXMLFromString(input);
if (xmlBlocks.length === 0) {
console.log('------------ USER ------------');
console.log('No XML tool invocations found in the input string.');
console.log('------------------------------');
return;
}
// Parse input
let calls: Array<{ name: string; arguments: Record<string, unknown> }>;
const executor = XMLToolExecutor.instance();
let accumulatedResults = '';
// Execute tools sequentially and format like ChatBox
for (let i = 0; i < xmlBlocks.length; i++) {
// Determine tool name from XML block (same as ChatBox lines 148-149)
const toolNameMatch = xmlBlocks[i].match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
try {
// Execute single XML block
const results = await executor.executeXMLTools(xmlBlocks[i]);
const result = results[0]; // Single block should give single result
if (result) {
// Format exactly like ChatBox lines 161-162
const formattedResult = `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
accumulatedResults += formattedResult;
}
} catch (error) {
// Handle individual tool error (same format)
const formattedResult = `tool: ${toolName}\nsuccess: false\nresult:\nTool execution failed: ${error}\n------------\n`;
accumulatedResults += formattedResult;
}
}
// Log accumulated results as USER (what gets sent back to LLM)
console.log('------------ USER ------------');
console.log(accumulatedResults);
console.log('------------------------------');
// Copy results to clipboard if possible
if (navigator.clipboard) {
navigator.clipboard.writeText(accumulatedResults).then(() => {
console.log("Tool execution results copied to clipboard!");
}).catch(() => {
console.log("Could not copy to clipboard (requires HTTPS)");
});
}
} catch (error) {
console.log('------------ USER ------------');
console.log(`Error testing XML tool execution: ${error}`);
console.log('------------------------------');
}
}
/**
* Test the AttemptCompletionTool with agent state integration
* @param comment - Completion comment to test with
*/
public async testAttemptCompletion(comment: string): Promise<void> {
console.log("🎯 Testing AttemptCompletionTool...");
console.log(`📝 Comment: "${comment}"`);
try {
// Get current agent state before test
const agentCore = AgentCore.instance();
const agentState = agentCore.getAgentState();
const initialTaskState = agentState.getIsWorkingOnTask();
console.log(`📊 Initial agent state:`);
console.log(` • isWorkingOnTask: ${initialTaskState}`);
// Set to working state to test the completion properly
if (!initialTaskState) {
console.log("🔄 Setting isWorkingOnTask to true for testing...");
agentState.setIsWorkingOnTask(true);
}
// Create and execute the tool
const completionTool = new AttemptCompletionTool();
const result = await completionTool.execute({ comment });
console.log(`✅ Tool execution result:`);
console.log(` • Success: ${result.success}`);
console.log(` • Result: ${result.result}`);
// Check final agent state
const finalTaskState = agentState.getIsWorkingOnTask();
console.log(`📊 Final agent state:`);
console.log(` • isWorkingOnTask: ${finalTaskState}`);
// Verify state change
if (result.success && finalTaskState === false) {
console.log("🎉 Success! Agent state correctly updated to not working on task.");
} else if (!result.success) {
console.log("⚠️ Tool execution failed - state may not have changed.");
if (typeof input === 'string') {
const parsed = JSON.parse(input);
calls = Array.isArray(parsed) ? parsed : [parsed];
} else if (Array.isArray(input)) {
calls = input as Array<{ name: string; arguments: Record<string, unknown> }>;
} else {
console.log("⚠️ Warning: State did not change as expected.");
calls = [input as { name: string; arguments: Record<string, unknown> }];
}
// Copy result to clipboard if possible
if (navigator.clipboard) {
const clipboardContent = JSON.stringify(result, null, 2);
navigator.clipboard.writeText(clipboardContent).then(() => {
console.log("📋 Test results copied to clipboard!");
}).catch(() => {
console.log("📋 Could not copy to clipboard (requires HTTPS)");
});
console.log(`🔧 Executing ${calls.length} tool call(s)...\n`);
for (let i = 0; i < calls.length; i++) {
const call = calls[i];
const toolName = call.name;
const toolArgs = call.arguments ?? {};
console.log(`── Tool call ${i + 1}/${calls.length}: ${toolName}`);
console.log(` Arguments: ${JSON.stringify(toolArgs, null, 2)}`);
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
if (!ToolClass) {
console.error(` ❌ Unknown tool: "${toolName}". Available tools: ${Object.keys(AVAILABLE_TOOLS).join(', ')}`);
continue;
}
const toolInstance = new ToolClass();
const result = await toolInstance.execute(toolArgs);
// Sync UI state on success
if (result.success) {
useProjectStore.getState().refreshProjectState();
}
const icon = result.success ? '✅' : '❌';
console.log(` ${icon} Success: ${result.success}`);
console.log(` Result: ${result.result}\n`);
}
console.log('🔧 Tool execution complete.');
} catch (error) {
console.error("❌ Error testing AttemptCompletionTool:", error);
console.error('❌ Error in testToolCall:', error);
console.log('💡 Expected format: {"name":"tool_name","arguments":{...}}');
console.log(' Or an array: [{"name":"tool1","arguments":{...}}, ...]');
}
}
@@ -401,8 +342,7 @@ export class KGDebugger {
console.log(" debugSelectedItems() - Show info about selected items");
console.log(" createTestRegion() - Create test region (not implemented)");
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
console.log(" testXMLToolExecution(input) - Test complete XML tool execution pipeline");
console.log(" testAttemptCompletion(comment) - Test AttemptCompletionTool with agent state");
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
console.log(" help() - Show this help");
console.log("");
@@ -410,9 +350,11 @@ export class KGDebugger {
console.log(" - Select regions in the DAW first, then run debug methods");
console.log(" - Results are logged to console and copied to clipboard when possible");
console.log(" - Use browser developer tools for best experience");
console.log(" - For XML testing, try: testExtractXMLFromString('I will <add_notes><note>...</note></add_notes> create notes');");
console.log(" - For full tool execution, try: await testXMLToolExecution('Create notes: <add_notes><note><pitch>C4</pitch><start_beat>0</start_beat><length>1</length></note></add_notes>');");
console.log(" - For completion testing, try: await testAttemptCompletion('Successfully created a C major chord');");
console.log("");
console.log("💡 testToolCall examples:");
console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start_beat":0,"length":8}}\')');
console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start_beat:0,length:1}]}})');
console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start_beat:0,end_beat:4}},{name:"read_music",arguments:{}}])');
}
/**
+1 -1
View File
@@ -190,7 +190,7 @@ export class ConfigManager {
},
claude_openrouter: {
api_key: '',
base_url: 'https://openrouter.ai/api/v1/chat/completions',
base_url: 'https://openrouter.ai/api/v1',
model: 'anthropic/claude-sonnet-4.5'
},
openai_compatible: {
+73 -40
View File
@@ -1,11 +1,12 @@
import { useState, useCallback } from 'react';
import { AgentCore } from '../agent/core/AgentCore';
import { createStreamingMessage } from '../utils/chatMessageUtils';
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
import type { ChatMessage } from '../types/projectTypes';
interface StreamProcessorOptions {
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
onMessageAdd: (message: ChatMessage) => void;
onMessageRemove: (messageId: string) => void;
onProcessingChange: (isProcessing: boolean) => void;
}
@@ -16,7 +17,7 @@ interface StreamProcessorResult {
}
export const useStreamProcessor = (options: StreamProcessorOptions): StreamProcessorResult => {
const { onMessageUpdate, onMessageAdd, onProcessingChange } = options;
const { onMessageUpdate, onMessageAdd, onMessageRemove, onProcessingChange } = options;
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -24,27 +25,24 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
setIsProcessing(true);
onProcessingChange(true);
// Create abort controller for this request
const controller = new AbortController();
setAbortController(controller);
// Add streaming assistant message
const streamingMessage = createStreamingMessage();
onMessageAdd(streamingMessage);
// Track the current streaming message ID (mutable)
let currentStreamingId = createStreamingMessage().id;
onMessageAdd({ id: currentStreamingId, role: 'assistant', content: '', isStreaming: true, tokenCount: 0 } as ChatMessage);
try {
const agentCore = AgentCore.instance();
let assistantResponse = '';
let tokenCount = 0;
let streamCompleted = false;
let hasTextContent = false;
// Log the input being sent to LLM
console.log(`------------ ${logPrefix} ------------`);
console.log(input);
console.log('------------------------------');
for await (const chunk of agentCore.processUserInput(input)) {
// Check if request was aborted
if (controller.signal.aborted) {
return '';
}
@@ -52,54 +50,89 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
if (chunk.type === 'text') {
assistantResponse += chunk.content;
tokenCount++;
// Update streaming message with token count and abort link
onMessageUpdate(streamingMessage.id, (msg) => ({
hasTextContent = true;
onMessageUpdate(currentStreamingId, (msg) => ({
...msg,
content: `<span class="processing-wave">Processing...</span> ${tokenCount} tokens received. click here to abort.`,
tokenCount
}));
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
// Finalize or remove the current streaming message
if (hasTextContent) {
onMessageUpdate(currentStreamingId, (msg) => ({
...msg,
content: assistantResponse,
isStreaming: false,
tokenCount: undefined
}));
console.log('------------ ASSISTANT ------------');
console.log(assistantResponse);
console.log('-----------------------------------');
} else {
// No text before this tool call — remove the empty streaming placeholder
onMessageRemove(currentStreamingId);
}
// Show tool call in UI
const toolName = chunk.toolCall.function.name;
let argsDisplay = '';
try {
const args = JSON.parse(chunk.toolCall.function.arguments);
argsDisplay = JSON.stringify(args, null, 2);
} catch {
argsDisplay = chunk.toolCall.function.arguments;
}
const toolCallMsg = createMessage('assistant', `🔧 **Calling tool: ${toolName}**\n\n\`\`\`json\n${argsDisplay}\n\`\`\``);
onMessageAdd(toolCallMsg);
} else if (chunk.type === 'tool_result' && chunk.toolResult) {
// Show tool result in UI
const { name, success, result } = chunk.toolResult;
const icon = success ? '✅' : '❌';
const toolResultMsg = createMessage('assistant', `${icon} **${name}**\n\n └── ${result}`);
onMessageAdd(toolResultMsg);
// Reset for the next LLM turn in the agentic loop
assistantResponse = '';
tokenCount = 0;
hasTextContent = false;
// Create a fresh streaming placeholder for the next LLM response
const nextMsg = createStreamingMessage();
currentStreamingId = nextMsg.id;
onMessageAdd(nextMsg);
} else if (chunk.type === 'done') {
streamCompleted = true;
// Replace with final response
onMessageUpdate(streamingMessage.id, (msg) => ({
...msg,
content: assistantResponse,
isStreaming: false,
tokenCount: undefined
}));
// Log the complete assistant response
// Finalize the streaming message
if (hasTextContent) {
onMessageUpdate(currentStreamingId, (msg) => ({
...msg,
content: assistantResponse,
isStreaming: false,
tokenCount: undefined
}));
} else {
// No text in final response — remove empty placeholder
onMessageRemove(currentStreamingId);
}
console.log('------------ ASSISTANT ------------');
console.log(assistantResponse);
console.log('-----------------------------------');
break;
}
}
// If stream didn't complete normally, finalize the message
if (!streamCompleted && !controller.signal.aborted) {
onMessageUpdate(streamingMessage.id, (msg) => ({
...msg,
content: assistantResponse || 'Stream was interrupted unexpectedly',
isStreaming: false,
tokenCount: undefined
}));
}
return assistantResponse;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Request was aborted, don't show error
return '';
}
console.error('Error processing stream:', error);
// Update with error message
onMessageUpdate(streamingMessage.id, (msg) => ({
onMessageUpdate(currentStreamingId, (msg) => ({
...msg,
content: 'Error: Failed to process message',
content: `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`,
isStreaming: false,
tokenCount: undefined
}));
@@ -109,11 +142,11 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
setIsProcessing(false);
onProcessingChange(false);
}
}, [onMessageUpdate, onMessageAdd, onProcessingChange]);
}, [onMessageUpdate, onMessageAdd, onMessageRemove, onProcessingChange]);
return {
processStream,
abortController,
isProcessing
};
};
};
-102
View File
@@ -1,102 +0,0 @@
import { XMLToolExecutor } from '../agent/core/XMLToolExecutor';
import { extractXMLFromString } from '../util/xmlUtil';
import { createToolResultMessage } from './chatMessageUtils';
import type { ChatMessage } from '../types/projectTypes';
interface ToolExecutionResult {
success: boolean;
result: string;
}
interface ToolExecutionOptions {
onMessageAdd: (message: ChatMessage) => void;
onStatusUpdate: (status: string) => void;
}
export const extractActionableTools = (response: string): string[] => {
const xmlBlocks = extractXMLFromString(response);
// Consider only actionable tools (exclude think/thinking)
return xmlBlocks.filter((block) => {
const match = block.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
const name = match ? match[1].toLowerCase() : '';
return name !== 'think' && name !== 'thinking';
});
};
export const extractToolName = (xmlBlock: string): string => {
const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/);
return toolNameMatch ? toolNameMatch[1] : 'unknown_tool';
};
export const executeSingleTool = async (
xmlBlock: string,
toolName: string,
options: ToolExecutionOptions
): Promise<ToolExecutionResult> => {
const { onMessageAdd } = options;
try {
const executor = XMLToolExecutor.instance();
const results = await executor.executeXMLTools(xmlBlock);
const result = results[0]; // Single block should give single result
if (result) {
// Add friendly display message
const toolMessage = createToolResultMessage(toolName, result.success, result.result);
onMessageAdd(toolMessage);
return {
success: result.success,
result: result.result
};
}
return {
success: false,
result: 'No result returned from tool execution'
};
} catch (error) {
// Handle individual tool error
const errorMessage = `Tool execution failed: ${error}`;
const toolMessage = createToolResultMessage(toolName, false, errorMessage);
onMessageAdd(toolMessage);
return {
success: false,
result: errorMessage
};
}
};
export const formatToolResultForLLM = (toolName: string, result: ToolExecutionResult): string => {
// Skip thinking tools
if (toolName === 'thinking' || toolName === 'think') {
return '';
}
return `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`;
};
export const executeAllTools = async (
actionableBlocks: string[],
options: ToolExecutionOptions
): Promise<string> => {
const { onStatusUpdate } = options;
onStatusUpdate(`Executing ${actionableBlocks.length} tool(s)...`);
let accumulatedResults = '';
// Execute tools sequentially with real-time updates
for (let i = 0; i < actionableBlocks.length; i++) {
onStatusUpdate(`Executing tool ${i + 1} of ${actionableBlocks.length}...`);
const toolName = extractToolName(actionableBlocks[i]);
const result = await executeSingleTool(actionableBlocks[i], toolName, options);
// Accumulate formatted result for LLM
accumulatedResults += formatToolResultForLLM(toolName, result);
}
return accumulatedResults;
};