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;