diff --git a/app/components/chat/APIKeyManager.tsx b/app/components/chat/APIKeyManager.tsx index 5b2c85e4..f070f60d 100644 --- a/app/components/chat/APIKeyManager.tsx +++ b/app/components/chat/APIKeyManager.tsx @@ -44,7 +44,7 @@ export const APIKeyManager: React.FC = ({ ) : ( <> - {apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'} + {apiKey ? '••••••••' : 'Not set (works via .env)'} setIsEditing(true)} title="Edit API Key">
diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 62020fd8..7208e76c 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -32,7 +32,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { const artifact = artifacts[messageId]; const actions = useStore( - computed(artifact.runner.actions, (actions) => { + computed(artifact?.runner?.actions, (actions) => { return Object.values(actions); }), ); @@ -187,6 +187,10 @@ const ActionList = memo(({ actions }: ActionListProps) => { > Start Application + ) : type == 'tool' ? ( +
+ {action.toolName} +
) : null}
{(type === 'shell' || type === 'start') && ( diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 629c5cbe..9b02d88e 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -15,6 +15,7 @@ import { APIKeyManager } from './APIKeyManager'; import Cookies from 'js-cookie'; import styles from './BaseChat.module.scss'; +import { ToolManager } from './ToolManager'; import type { ProviderInfo } from '~/utils/types'; const EXAMPLE_PROMPTS = [ @@ -85,6 +86,7 @@ interface BaseChatProps { sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; + } export const BaseChat = React.forwardRef( @@ -113,8 +115,13 @@ export const BaseChat = React.forwardRef( ) => { const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; const [apiKeys, setApiKeys] = useState>({}); + const [toolConfig, setToolConfig] = useState({ + enabled: false, + config: {} + }); + const [modelList, setModelList] = useState(MODEL_LIST); - + useEffect(() => { // Load API keys from cookies on component mount try { @@ -136,6 +143,25 @@ export const BaseChat = React.forwardRef( }); }, []); + useEffect(() => { + const config = Cookies.get('bolt.toolsConfig'); + if (config) { + try { + const parsedConfig = JSON.parse(config); + setToolConfig(parsedConfig); + } catch (error) { + console.error('Error parsing tools config:', error); + // Clear invalid cookie data + Cookies.remove('bolt.toolsConfig'); + } + } + else{ + Cookies.set('bolt.toolsConfig', JSON.stringify(toolConfig), { + path: '/', // Accessible across the site + }); + } + }, []); + const updateApiKey = (provider: string, key: string) => { try { const updatedApiKeys = { ...apiKeys, [provider]: key }; @@ -203,15 +229,24 @@ export const BaseChat = React.forwardRef( modelList={modelList} provider={provider} setProvider={setProvider} - providerList={PROVIDER_LIST} + + providerList={providerList} /> - {provider && ( +
+ { + setToolConfig(config) + Cookies.set('bolt.toolsConfig', JSON.stringify(config), { + path: '/', // Accessible across the site + }); + }} /> + {provider && ( updateApiKey(provider.name, key)} /> )} +
>({}); + const [toolConfig, setToolConfig] = useState({ + enabled: false, + config: {} + }); + + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ api: '/api/chat', body: { - apiKeys + apiKeys, + toolConfig }, onError: (error) => { logger.error('Request failed\n\n', error); @@ -104,21 +110,36 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp }, initialMessages, }); + const waitForLoading = useWaitForLoading(isLoading); const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); const { parsedMessages, parseMessages } = useMessageParser(); const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + useEffect(() => { chatStore.setKey('started', initialMessages.length > 0); + let toolConfig=Cookies.get('bolt.toolsConfig'); + if (toolConfig) { + try { + const parsedConfig = JSON.parse(toolConfig); + setToolConfig(parsedConfig); + } catch (error) { + console.error('Error parsing tools config:', error); + // Clear invalid cookie data + Cookies.remove('bolt.toolsConfig'); + } + } }, []); useEffect(() => { parseMessages(messages, isLoading); - + if (messages.length > initialMessages.length) { - storeMessageHistory(messages).catch((error) => toast.error(error.message)); + // filter out tool responses as it will be automatically added by the runner + let filteredMessages = messages.filter((message) => !message.annotations?.find((a) => a === 'toolResponse')); + storeMessageHistory(filteredMessages).catch((error) => toast.error(error.message)); } }, [messages, isLoading, parseMessages]); @@ -164,7 +185,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp setChatStarted(true); }; - const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { + const sendMessage = async (_event: React.UIEvent, messageInput?: string,annotations?:string[]) => { const _input = messageInput || input; if (_input.length === 0 || isLoading) { @@ -196,7 +217,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp * manually reset the input and we'd have to manually pass in file attachments. However, those * aren't relevant here. */ - append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` }); + + append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}`,annotations }); /** * After sending a new message we reset all modifications since the model @@ -213,6 +235,19 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp textareaRef.current?.blur(); }; + useEffect(() => { + workbenchStore.addChatMessage = async(message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions) => { + await waitForLoading(); + return await append( + { + ...message, + content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${message.content}`, + }, + chatRequestOptions, + ); + } + }, [append]); + const [messageRef, scrollRef] = useSnapScroll(); @@ -270,7 +305,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp scrollTextArea(); }, model, - provider, + provider.name, apiKeys ); }} diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 2f35f49d..cf61cf6a 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -17,11 +17,14 @@ export const Messages = React.forwardRef((props: return (
{messages.length > 0 - ? messages.map((message, index) => { - const { role, content } = message; - const isUserMessage = role === 'user'; - const isFirst = index === 0; - const isLast = index === messages.length - 1; + ? messages + // filter out tool responses + .filter((message) => !(message.annotations?.find((a) => a === 'toolResponse'))) + .map((message, index) => { + const { role, content } = message; + const isUserMessage = role === 'user'; + const isFirst = index === 0; + const isLast = index === messages.length - 1; return (
void; +} + +export function ToolManager({ toolConfig, onConfigChange }: ToolManagerProps) { + return ( + <> + {toolConfig && ( +
+
+ + {/*
*/} + { + onConfigChange?.({ + enabled: e, + config: toolConfig.config, + }); + }} + /> +
+
+ )} + + ); +} diff --git a/app/components/ui/ToggleSwitch.tsx b/app/components/ui/ToggleSwitch.tsx new file mode 100644 index 00000000..1e68c7b5 --- /dev/null +++ b/app/components/ui/ToggleSwitch.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; + +const ToggleSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; + +export { ToggleSwitch }; diff --git a/app/lib/.server/llm/stream-text.ts b/app/lib/.server/llm/stream-text.ts index 8bfd7978..8707c7b4 100644 --- a/app/lib/.server/llm/stream-text.ts +++ b/app/lib/.server/llm/stream-text.ts @@ -1,14 +1,16 @@ -// @ts-nocheck -// Preventing TS checks with files presented in the video for a better presentation. -import { streamText as _streamText, convertToCoreMessages } from 'ai'; +import { streamText as _streamText, convertToCoreMessages, generateText } from 'ai'; import { getModel } from '~/lib/.server/llm/model'; import { MAX_TOKENS } from './constants'; import { getSystemPrompt } from './prompts'; import { MODEL_LIST, DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; +import { agentRegistry, prebuiltAgents } from '~/utils/agentFactory'; +import { AgentOutputParser } from '~/lib/agents/agent-output-parser'; +import type { IToolsConfig } from '~/utils/types'; interface ToolResult { toolCallId: string; toolName: Name; + state: 'call' | 'partial-call' | 'result'; args: Args; result: Result; } @@ -31,7 +33,7 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid // Extract provider const providerMatch = message.content.match(PROVIDER_REGEX); - const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER; + const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name; // Remove model and provider lines from content const cleanedContent = message.content @@ -42,14 +44,52 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid return { model, provider, content: cleanedContent }; } -export function streamText( - messages: Messages, - env: Env, +async function generateSystemPrompt(messages: Messages, + env: Env, + currentProvider: string, currentModel: string, options?: StreamingOptions, - apiKeys?: Record + apiKeys?: Record, +) { + const coordinatorAgent = prebuiltAgents.coordinatorAgent + let coordSystemPrompt = coordinatorAgent.generatePrompt() + + + let { text: coordResp } = await generateText({ + model: getModel(currentProvider, currentModel, env, apiKeys) as any, + system: coordSystemPrompt, + maxTokens: MAX_TOKENS, + messages: convertToCoreMessages(messages), + ...options, + }) + let agentOutputParser = new AgentOutputParser(); + let coordOutput = agentOutputParser.parse(`${Date.now()}`, coordResp) + + let agentSystemPrompt = getSystemPrompt(); + if (coordOutput.event && coordOutput.event.type == 'toolCallComplete') { + let { name, parameters } = coordOutput.event + if (name == 'routeToAgent') { + try { + let confidence = parseFloat(parameters?.confidence || '0') + if (confidence > 0.5 && parameters?.agentId) { + let agent = agentRegistry[parameters?.agentId] + if (agent) return agent.generatePrompt() + } + } catch (e) { + } + } + } + return agentSystemPrompt +} + +export async function streamText( + messages: Messages, + env: Env, + options?: StreamingOptions, + apiKeys?: Record, + toolConfig?: IToolsConfig ) { let currentModel = DEFAULT_MODEL; - let currentProvider = DEFAULT_PROVIDER; + let currentProvider = DEFAULT_PROVIDER.name; const processedMessages = messages.map((message) => { if (message.role === 'user') { @@ -66,10 +106,15 @@ export function streamText( return message; // No changes for non-user messages }); - - return _streamText({ - model: getModel(currentProvider, currentModel, env, apiKeys), - system: getSystemPrompt(), + + let systemPrompt = getSystemPrompt(); + if (toolConfig&&toolConfig.enabled) { + systemPrompt= await generateSystemPrompt(processedMessages, env, currentProvider, currentModel, options, apiKeys) + } + + return _streamText({ + model: getModel(currentProvider, currentModel, env, apiKeys) as any, + system: systemPrompt, maxTokens: MAX_TOKENS, messages: convertToCoreMessages(processedMessages), ...options, diff --git a/app/lib/agents/agent-generator.ts b/app/lib/agents/agent-generator.ts new file mode 100644 index 00000000..851c45f6 --- /dev/null +++ b/app/lib/agents/agent-generator.ts @@ -0,0 +1,407 @@ +import type { AgentConfig } from "~/types/agent"; +import type { ParameterConfig, ToolConfig } from "~/types/tools"; + +/** + * Generates structured prompts for AI agents based on their configuration + * @class AgentPromptGenerator + */ +class AgentPromptGenerator { + /** Configuration object for the agent */ + private config: AgentConfig; + /** Storage for template variable values */ + private templateValues: Record = {}; + + /** + * Creates a new instance of AgentPromptGenerator + * @param config - The configuration object for the agent + */ + constructor(config: AgentConfig) { + this.config = config; + } + + + /** + * Sets a value for a template variable + * @param name - Name of the template variable + * @param value - Value to set for the template variable + * @throws Error if the template variable is not defined in the agent config + */ + setTemplateValue(name: string, value: any): void { + if (!this.config.templateVariables?.some(v => v.name === name)) { + throw new Error(`Template variable ${name} not defined in agent config`); + } + this.templateValues[name] = value; + } + + /** + * Generates a complete prompt string based on the agent's configuration + * @returns A formatted string containing the complete agent prompt + */ + generatePrompt(): string { + // Build the complete prompt with all sections + return ` +You are a ${this.config.description} with ONE purpose: ${this.config.purpose}. +You MUST respond ONLY with a toolCall tag structure. +Your agentId is: ${this.config.agentId} + +${this.generateTemplateVariablesSection()} + +## Available Tools + +${this.generateToolsSection()} + +## Response Format + +You MUST respond using ONLY this exact structure: + + {parameter_value} + + +Where: +- tool_name must be one of: ${this.config.tools.map(t => `"${t.name}"`).join(', ')} +- agentId must always be "${this.config.agentId}" + +${this.generateRulesSection()} + +## Example Valid Responses + +${this.generateExamplesSection()} + +## STRICT RULES + +1. Tag Structure Rules: + - MUST use exactly one toolCall tag as the root + - MUST set name attribute to one of the available tool names + - MUST include agentId="${this.config.agentId}" in every toolCall tag + - MUST include all required parameters for the chosen tool + - MUST use proper tag nesting and indentation + - MUST NOT add any additional tags or attributes besides name and agentId + - MUST NOT modify tag names or structure + +2. Parameter Rules: + - MUST include all required parameters for the selected tool + - MUST use exact parameter names as specified + - MUST provide values of the correct type + - MUST NOT add additional parameters + +3. Format Rules: + - MUST NOT include XML declaration + - MUST NOT include any other content outside the toolCall tag + - MUST use proper XML escaping for special characters + - MUST maintain consistent indentation + +4. AgentId Rules: + - MUST always include agentId="${this.config.agentId}" + - MUST NOT modify or change the agentId + - MUST NOT omit the agentId + +NO EXCEPTIONS TO THESE RULES ARE ALLOWED.`; + } + + /** + * Generates the tools section of the prompt + * @returns Formatted string containing tool descriptions and parameters + * @private + */ + private generateToolsSection(): string { + return this.config.tools.map(tool => ` +### ${tool.name} +${tool.description} + +Parameters: +${tool.parameters.map(param => + `- ${param.name}: ${param.type} + ${param.description}` + ).join('\n\n')}` + ).join('\n\n'); + } + + /** + * Generates the template variables section of the prompt + * @returns Formatted string containing template variable values + * @private + */ + private generateTemplateVariablesSection(): string { + let sections = []; + + for (const variable of this.config.templateVariables || []) { + const value = this.templateValues[variable.name]; + if (value) { + sections.push(` +<<${variable.name.toUpperCase()}>> +${typeof value === 'string' ? value : JSON.stringify(value, null, 2)} +<>`); + } + } + + if (sections.length > 0) { + let sectionHeader = '## Context Variables'; + sections = [sectionHeader, ...sections]; + } + return sections.join('\n\n'); + } + + /** + * Generates the rules section of the prompt + * @returns Formatted string containing categorized rules + * @private + */ + private generateRulesSection(): string { + return this.config.rules + .map(category => `## ${category.category}\n\n${category.items.map(rule => `- ${rule}`).join('\n')}`) + .join('\n\n'); + } + + /** + * Generates example responses for each tool + * @returns Formatted string containing example tool calls + * @private + */ + private generateExamplesSection(): string { + return this.config.tools.map(tool => + `Example using ${tool.name}: + +${tool.parameters.map(param => + ` ${param.example}` + ).join('\n')} +` + ).join('\n\n'); + } +} + + + +/** + * Represents the response from a tool execution + * @interface ToolResponse + */ +interface ToolResponse { + /** Status of the tool execution */ + status: 'success' | 'error'; + /** Result message from the tool */ + message: string; + /** Any additional data returned by the tool */ + data?: any; +} + +/** + * Configuration interface for agent initialization + * @interface AgentInitConfig + */ +interface AgentInitConfig { + /** Template values to be set during initialization */ + templateValues?: Record; +} + +/** + * Main Agent class that encapsulates prompt generation and tool execution + * @class Agent + */ +export class Agent { + /** Agent configuration */ + private config: AgentConfig; + /** Prompt generator instance */ + private promptGenerator: AgentPromptGenerator; + /** Map of tool implementations */ + private tools: Map; + + /** + * Creates a new Agent instance + * @param config - Agent configuration + * @param initConfig - Optional initialization configuration + */ + constructor(config: AgentConfig, initConfig: AgentInitConfig = {}) { + this.validateConfig(config); + this.config = config; + this.promptGenerator = new AgentPromptGenerator(config); + this.tools = new Map(config.tools.map(tool => [tool.name, tool])); + + // Set any initial template values + if (initConfig.templateValues) { + for (const [key, value] of Object.entries(initConfig.templateValues)) { + this.setTemplateValue(key, value); + } + } + } + + /** + * Validates the agent configuration + * @param config - Configuration to validate + * @throws Error if configuration is invalid + * @private + */ + private validateConfig(config: AgentConfig): void { + if (!config.agentId) { + throw new Error('Agent ID is required'); + } + if (!config.tools || config.tools.length === 0) { + throw new Error('At least one tool must be configured'); + } + + // Validate tools + const toolNames = new Set(); + for (const tool of config.tools) { + if (!tool.name || !tool.execute) { + throw new Error('Tool name and execute function are required'); + } + if (toolNames.has(tool.name)) { + throw new Error(`Duplicate tool name: ${tool.name}`); + } + toolNames.add(tool.name); + + // Validate parameters + if (tool.parameters) { + for (const param of tool.parameters) { + if (!param.name || !param.type) { + throw new Error(`Invalid parameter configuration in tool ${tool.name}`); + } + } + } + } + } + + /** + * Sets a template value for prompt generation + * @param name - Template variable name + * @param value - Template variable value + */ + setTemplateValue(name: string, value: any): void { + this.promptGenerator.setTemplateValue(name, value); + } + + /** + * Generates the agent's prompt + * @returns Generated prompt string + */ + generatePrompt(): string { + return this.promptGenerator.generatePrompt(); + } + + /** + * Executes a tool with the given name and parameters + * @param toolName - Name of the tool to execute + * @param parameters - Parameters for the tool + * @returns Promise resolving to tool execution result + */ + async executeTool(toolName: string, parameters: Record): Promise { + const tool = this.tools.get(toolName); + if (!tool) { + return { + status: 'error', + message: `Tool not found: ${toolName}` + }; + } + + // Validate parameters + const validation = this.validateToolParameters(toolName, parameters); + if (!validation.valid) { + return { + status: 'error', + message: `Parameter validation failed: ${validation.errors?.join(', ')}` + }; + } + + try { + const result = await tool.execute(parameters); + return { + status: 'success', + message: result + }; + } catch (error) { + return { + status: 'error', + message: error instanceof Error ? error.message : 'Tool execution failed' + }; + } + } + + /** + * Validates tool parameters against their configuration + * @param toolName - Name of the tool + * @param parameters - Parameters to validate + * @returns Validation result object + */ + validateToolParameters(toolName: string, parameters: Record): { + valid: boolean; + errors?: string[]; + } { + const tool = this.tools.get(toolName); + if (!tool) { + return { valid: false, errors: [`Tool not found: ${toolName}`] }; + } + + const errors: string[] = []; + const providedParams = new Set(Object.keys(parameters)); + const requiredParams = new Set(tool.parameters.map(p => p.name)); + + // Check for required parameters + for (const param of tool.parameters) { + if (!providedParams.has(param.name)) { + errors.push(`Missing required parameter: ${param.name}`); + } + } + + // Check for unknown parameters + for (const paramName of providedParams) { + if (!requiredParams.has(paramName)) { + errors.push(`Unknown parameter: ${paramName}`); + } + } + + return { + valid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined + }; + } + + /** + * Returns the tool configuration for a given tool name + * @param toolName - Name of the tool + * @returns Tool configuration or undefined if not found + */ + getTool(toolName: string): ToolConfig | undefined { + return this.tools.get(toolName); + } + + /** + * Returns all configured tools + * @returns Array of tool configurations + */ + getTools(): ToolConfig[] { + return Array.from(this.tools.values()); + } + + /** + * Returns the agent's configuration + * @returns Agent configuration + */ + getConfig(): AgentConfig { + return this.config; + } + + /** + * Checks if a tool exists + * @param toolName - Name of the tool + * @returns boolean indicating if the tool exists + */ + hasTool(toolName: string): boolean { + return this.tools.has(toolName); + } + + /** + * Gets a list of all tool names + * @returns Array of tool names + */ + getToolNames(): string[] { + return Array.from(this.tools.keys()); + } + + /** + * Gets the parameters configuration for a specific tool + * @param toolName - Name of the tool + * @returns Array of parameter configurations or undefined if tool not found + */ + getToolParameters(toolName: string): ParameterConfig[] | undefined { + return this.tools.get(toolName)?.parameters; + } +} \ No newline at end of file diff --git a/app/lib/agents/agent-output-parser.ts b/app/lib/agents/agent-output-parser.ts new file mode 100644 index 00000000..d0347ab4 --- /dev/null +++ b/app/lib/agents/agent-output-parser.ts @@ -0,0 +1,346 @@ +/** + * Represents the current state of the XML parser + * @type ParserState + */ +type ParserState = 'IDLE' | 'IN_TOOL_CALL' | 'IN_PARAMETER' | 'COLLECTING_TAG'; + +/** + * Represents an event that occurs during tool call parsing + * @interface ToolCallEvent + */ +interface ToolCallEvent { + /** Unique identifier for the message being parsed */ + messageId: string; + /** Type of the event - either start or completion of a tool call */ + type: 'toolCallStart' | 'toolCallComplete'; + /** Unique identifier for the tool call */ + id: string; + /** Name of the tool being called */ + name: string; + /** Identifier of the agent making the tool call */ + agentId: string; + /** Optional parameters passed to the tool */ + parameters?: Record; +} + +/** + * Callback functions for handling tool call events + * @interface ParserCallbacks + */ +interface ParserCallbacks { + /** Callback for when a tool call starts */ + onToolCallStart?: (event: ToolCallEvent) => void; + /** Callback for when a tool call completes */ + onToolCallComplete?: (event: ToolCallEvent) => void; +} + +/** + * Maintains the state of the parser for a specific message + * @interface ParserCursor + */ +interface ParserCursor { + /** Current position in the input string */ + position: number; + /** Current state of the parser */ + state: ParserState; + /** ID of the current tool call being processed */ + currentToolCallId: string | null; + /** Buffer for collecting tag content */ + tagBuffer: string; + /** Name of the current attribute being processed */ + currentAttributeName: string; + /** Value of the current attribute being processed */ + currentAttributeValue: string; + /** Collected parameters for the current tool call */ + parameters: Record; + /** Name of the current tool call */ + name?: string; + /** Agent ID for the current tool call */ + agentId?: string; + /** Current nesting depth of XML tags */ + depth: number; + /** Current index of the current action */ + actionIndex: number; +} + +/** + * Parser for processing XML-formatted agent output containing tool calls + * Implements a streaming parser that can process input incrementally + * @class AgentOutputParser + */ +export class AgentOutputParser { + /** Map of message IDs to their corresponding parser cursors */ + private cursors: Map; + /** Callback functions for parser events */ + private callbacks: ParserCallbacks; + + /** + * Creates a new instance of AgentOutputParser + * @param callbacks - Optional callback functions for parser events + */ + constructor(callbacks: ParserCallbacks = {}) { + this.callbacks = callbacks; + this.cursors = new Map(); + } + + /** + * Returns the opening tag string for tool calls + * @returns The tool call opening tag + */ + getToolCallTagOpen(): string { + return "') { + let {cursor:newCursor,event} = this.handleToolCallEnd(messageId, cursor); + return { cursor:newCursor, event }; + } else if (cursor.tagBuffer === '') { + this.handleParameterEnd(cursor); + } else if (cursor.tagBuffer.length > 15) { + // Reset if tag is too long to be valid + cursor.state = 'IDLE'; + cursor.tagBuffer = ''; + } + break; + + case 'IN_TOOL_CALL': + // Collect and process tool call attributes + if (char === '>') { + const attributes = this.processAttributes(cursor.tagBuffer); + if (attributes.name && attributes.agentId) { + this.handleToolCallStart(messageId, cursor, { name: attributes.name, agentId: attributes.agentId, ...attributes }); + } + cursor.tagBuffer = ''; + cursor.state = 'IDLE'; + } else { + cursor.tagBuffer += char; + } + break; + + case 'IN_PARAMETER': + // Process parameter name and value + if (cursor.currentAttributeName.trim()=='') { + if (char === '>' && cursor.tagBuffer.includes('name=')) { + const paramName = this.extractAttributeValue(cursor.tagBuffer, 'name'); + if (paramName) { + cursor.currentAttributeName = paramName; + cursor.currentAttributeValue = ''; + } + cursor.tagBuffer = ''; + // cursor.state = 'IDLE'; + } else { + cursor.tagBuffer += char; + } + } else { + if (char === '<') { + cursor.tagBuffer = char; + cursor.state = 'COLLECTING_TAG'; + break; + } else { + cursor.currentAttributeValue += char; + } + } + break; + } + + cursor.position++; + } + + return { cursor }; + } + + /** + * Handles the start of a tool call + * @param messageId - ID of the current message + * @param cursor - Current parser cursor + * @param attributes - Tool call attributes + * @returns ToolCallEvent for the start of the tool call + * @private + */ + private handleToolCallStart(messageId: string, cursor: ParserCursor, attributes: { name: string; agentId: string }) { + cursor.name = attributes.name; + cursor.agentId = attributes.agentId; + if (cursor.currentToolCallId == null) { + return { cursor }; + } + let event: ToolCallEvent = { + messageId, + type: 'toolCallStart', + id:cursor.currentToolCallId, + name: attributes.name, + agentId: attributes.agentId + } + if (this.callbacks.onToolCallStart) { + this.callbacks.onToolCallStart(event); + } + return event; + } + + /** + * Handles the end of a tool call + * @param messageId - ID of the current message + * @param cursor - Current parser cursor + * @returns ToolCallEvent for the completion of the tool call + * @private + */ + private handleToolCallEnd(messageId: string, cursor: ParserCursor) { + if (cursor.currentToolCallId == null) { + return{cursor}; + } + let event: ToolCallEvent = { + messageId, + type: 'toolCallComplete', + id: cursor.currentToolCallId, + name: cursor.name!, + agentId: cursor.agentId!, + parameters: cursor.parameters + } + if (this.callbacks.onToolCallComplete) { + this.callbacks.onToolCallComplete(event); + } + + // Reset cursor state but maintain the position + const position = cursor.position; + const newCursor = this.getInitialCursor(); + newCursor.position = position+ + newCursor.position++; + this.cursors.set(messageId, newCursor); + return { cursor:newCursor,event}; + } + + /** + * Handles the end of a parameter tag + * @param cursor - Current parser cursor + * @private + */ + private handleParameterEnd(cursor: ParserCursor) { + if (cursor.currentAttributeName) { + cursor.parameters[cursor.currentAttributeName] = + cursor.currentAttributeValue.trim(); + cursor.currentAttributeName = ''; + cursor.currentAttributeValue = ''; + } + cursor.state = 'IDLE'; + cursor.tagBuffer = ''; + } + + /** + * Processes attributes from a tag buffer + * @param buffer - Buffer containing tag attributes + * @returns Object containing extracted name and agentId + * @private + */ + private processAttributes(buffer: string): { name?: string; agentId?: string } { + const nameMatch = buffer.match(/name="([^"]+)"/); + const agentIdMatch = buffer.match(/agentId="([^"]+)"/); + + return { + name: nameMatch?.[1], + agentId: agentIdMatch?.[1] + }; + } + + /** + * Extracts an attribute value from a tag buffer + * @param buffer - Buffer containing tag attributes + * @param attributeName - Name of the attribute to extract + * @returns Extracted attribute value or null if not found + * @private + */ + private extractAttributeValue(buffer: string, attributeName: string): string | null { + const match = buffer.match(new RegExp(`${attributeName}="([^"]+)"`)); + return match?.[1] || null; + } + + /** + * Generates a unique ID for a tool call + * @returns Unique tool call ID + * @private + */ + private generateToolCallId(cursor:ParserCursor): string { + return `${cursor.actionIndex++}`; + } + + /** + * Resets the parser state for a specific message while maintaining position + * @param messageId - ID of the message to reset + */ + resetMessage(messageId: string): void { + const cursor = this.cursors.get(messageId); + if (cursor) { + const position = cursor.position; + const newCursor = this.getInitialCursor(); + newCursor.position = position; + this.cursors.set(messageId, newCursor); + } + } + + /** + * Removes all parser state for a specific message + * @param messageId - ID of the message to remove + */ + removeMessage(messageId: string): void { + this.cursors.delete(messageId); + } + reset() { + this.cursors.clear(); + } +} \ No newline at end of file diff --git a/app/lib/hooks/useMessageParser.ts b/app/lib/hooks/useMessageParser.ts index 97a063da..dfb79fb9 100644 --- a/app/lib/hooks/useMessageParser.ts +++ b/app/lib/hooks/useMessageParser.ts @@ -3,9 +3,56 @@ import { useCallback, useState } from 'react'; import { StreamingMessageParser } from '~/lib/runtime/message-parser'; import { workbenchStore } from '~/lib/stores/workbench'; import { createScopedLogger } from '~/utils/logger'; +import { AgentOutputParser } from '../agents/agent-output-parser'; +import { agentRegistry } from '~/utils/agentFactory'; +import type { ToolAction } from '~/types/actions'; const logger = createScopedLogger('useMessageParser'); +const agentOutputParser = new AgentOutputParser({ + onToolCallStart: (event) => { + + logger.trace('onToolCallStart', event); + let artifactData = { + messageId: event.messageId, + title: `Agent: ${agentRegistry[event.agentId]?.getConfig().name}`, + id: event.id + } + workbenchStore.addArtifact(artifactData); + }, + onToolCallComplete: (event) => { + logger.trace('onToolCallComplete', event); + let artifactData = { + messageId: event.messageId, + title: `Agent: ${agentRegistry[event.agentId]?.getConfig().name}`, + id: event.id + } + + let actionData: ToolAction = { + type: 'tool', + agentId: event.agentId, + toolName: event.name, + content: JSON.stringify(event.parameters), + parameters: event.parameters, + } + + workbenchStore.addAction({ + messageId: event.messageId, + actionId: event.id, + artifactId: event.id, + action: actionData + }); + + workbenchStore.runAction({ + messageId: event.messageId, + actionId: event.id, + artifactId: event.id, + action: actionData + }) + workbenchStore.updateArtifact(artifactData, { closed: true}) + }, + +}); const messageParser = new StreamingMessageParser({ callbacks: { onArtifactOpen: (data) => { @@ -41,8 +88,11 @@ const messageParser = new StreamingMessageParser({ workbenchStore.runAction(data, true); }, }, + agentOutputParser }); + + export function useMessageParser() { const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({}); @@ -52,6 +102,7 @@ export function useMessageParser() { if (import.meta.env.DEV && !isLoading) { reset = true; messageParser.reset(); + agentOutputParser.reset(); } for (const [index, message] of messages.entries()) { diff --git a/app/lib/hooks/useWaitForLoading.ts b/app/lib/hooks/useWaitForLoading.ts new file mode 100644 index 00000000..c208536d --- /dev/null +++ b/app/lib/hooks/useWaitForLoading.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useWaitForLoading = (isLoading:boolean) => { + const pendingPromises = useRef(new Set<{resolve:()=>void,reject:(error:Error)=>void}>()); + + // Cleanup and reject pending promises on unmount + useEffect(() => { + return () => { + pendingPromises.current.forEach(({ reject }) => { + reject(new Error('Component unmounted')); + }); + pendingPromises.current.clear(); + }; + }, []); + + // Resolve promises when loading completes + useEffect(() => { + if (!isLoading) { + pendingPromises.current.forEach(({ resolve }) => resolve()); + pendingPromises.current.clear(); + } + }, [isLoading]); + + return useCallback(() => { + if (!isLoading) return Promise.resolve(); + + return new Promise((resolve, reject) => { + pendingPromises.current.add({ resolve, reject }); + }); + }, [isLoading]); +}; \ No newline at end of file diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index f94390be..2cbb4b09 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -5,8 +5,8 @@ import type { BoltAction } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; -import type { ITerminal } from '~/types/terminal'; import type { BoltShell } from '~/utils/shell'; +import { agentRegistry } from '~/utils/agentFactory'; const logger = createScopedLogger('ActionRunner'); @@ -77,7 +77,7 @@ export class ActionRunner { }); } - async runAction(data: ActionCallbackData, isStreaming: boolean = false) { + async runAction(data: ActionCallbackData, isStreaming: boolean = false,onFinish?:(data:ActionCallbackData)=>void) { const { actionId } = data; const action = this.actions.get()[actionId]; @@ -91,12 +91,15 @@ export class ActionRunner { if (isStreaming && action.type !== 'file') { return; } - + this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming }); this.#currentExecutionPromise = this.#currentExecutionPromise .then(() => { return this.#executeAction(actionId, isStreaming); + }).then(()=>{ + onFinish?.({...data}); + return ; }) .catch((error) => { console.error('Action failed:', error); @@ -105,7 +108,7 @@ export class ActionRunner { async #executeAction(actionId: string, isStreaming: boolean = false) { const action = this.actions.get()[actionId]; - + this.#updateAction(actionId, { status: 'running' }); try { @@ -122,6 +125,11 @@ export class ActionRunner { await this.#runStartAction(action) break; } + case 'tool': { + let resp=await this.#runToolAction(action); + this.#updateAction(actionId,{ result:resp } as any); + break; + } } this.#updateAction(actionId, { status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete' }); @@ -200,6 +208,25 @@ export class ActionRunner { logger.error('Failed to write file\n\n', error); } } + + async #runToolAction(action: ActionState) { + if (action.type !== 'tool') { + unreachable('Expected tool action'); + } + const agent = agentRegistry[action.agentId]; + if (!agent) { + unreachable('Agent not found'); + } + if (!action.parameters) { + unreachable('Parameters not found'); + } + let { valid, errors } = agent.validateToolParameters(action.toolName, action.parameters); + if (!valid) { + throw new Error("Error In Tool parameters: " + errors?.join(', ')); + } + const result = await agent.executeTool(action.toolName, action.parameters); + return result; + } #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); diff --git a/app/lib/runtime/message-parser.spec.ts b/app/lib/runtime/message-parser.spec.ts index 739604bc..33ba3e26 100644 --- a/app/lib/runtime/message-parser.spec.ts +++ b/app/lib/runtime/message-parser.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { StreamingMessageParser, type ActionCallback, type ArtifactCallback } from './message-parser'; +import { StreamingMessageParser, type ActionCallback, type ArtifactCallback, type ParserCallbacks } from './message-parser'; +import { AgentOutputParser } from '../agents/agent-output-parser'; interface ExpectedResult { output: string; @@ -154,6 +155,131 @@ describe('StreamingMessageParser', () => { }); }); +describe('StreamingMessageParser - ToolCall Parsing', () => { + describe('toolCall parsing', () => { + it.each<[string | string[], ExpectedResult | string]>([ + [ + '\nproject-scaffold\nBuild a todo app\n0.9\n', + { + output: '', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test chunked streaming of toolCall + [ + [ + '\n', + 'project-scaffold\n', + 'Build a todo app\n', + '0.9\n', + '' + ], + { + output: '', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test toolCall within artifact + [ + 'Before \nproject-scaffold\n After', + { + output: 'Before After', + callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test incomplete toolCall + [ + '\nproject-scaffold', + { + output: '', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test malformed toolCall + [ + 'value', + { + output: '', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test toolCall with text before and after + [ + 'Some text before \nproject-scaffold\n Some text after', + { + output: 'Some text before ', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + // Test nested toolCalls (should treat as invalid) + [ + '', + { + output: '', + callbacks: { onArtifactOpen: 0, onArtifactClose: 0, onActionOpen: 0, onActionClose: 0 }, + }, + ], + ])('should correctly parse toolCall chunks (%#)', (input, outputOrExpectedResult) => { + let expected: ExpectedResult; + if (typeof outputOrExpectedResult === 'string') { + expected = { output: outputOrExpectedResult }; + } else { + expected = outputOrExpectedResult; + } + const mockAgentOutputParser = { + getToolCallTagOpen: () => ' { + if (input.includes('')) { + return { + cursor: { position: input.indexOf('') + ''.length }, + event: { type: 'toolCallComplete' } + }; + } + return { cursor: { position: 0 }, event: null }; + }) + }; + + const parser = new StreamingMessageParser({ + artifactElement: () => '', + callbacks: { + onArtifactOpen: vi.fn(), + onArtifactClose: vi.fn(), + onActionOpen: vi.fn(), + onActionClose: vi.fn(), + }, + agentOutputParser: mockAgentOutputParser as unknown as AgentOutputParser + }); + + let message = ''; + let result = ''; + const chunks = Array.isArray(input) ? input : [input]; + + for (const chunk of chunks) { + message += chunk; + result += parser.parse('message_1', message); + } + + expect(result).toEqual(expected.output); + + // Verify callbacks were called correct number of times + if (expected.callbacks) { + for (const [name, count] of Object.entries(expected.callbacks)) { + type CallbackName = keyof ParserCallbacks; + if (name === 'onArtifactOpen' || name === 'onArtifactClose' || + name === 'onActionOpen' || name === 'onActionStream' || name === 'onActionClose') { + const callbacks=parser['_options'].callbacks + const callback = callbacks?.[name as CallbackName]; + if (callback && typeof callback === 'function') { + expect(callback).toHaveBeenCalledTimes(count); + } + } + } + } + }); + }); +}); + function runTest(input: string | string[], outputOrExpectedResult: string | ExpectedResult) { let expected: ExpectedResult; @@ -181,6 +307,7 @@ function runTest(input: string | string[], outputOrExpectedResult: string | Expe const parser = new StreamingMessageParser({ artifactElement: () => '', callbacks, + agentOutputParser: new AgentOutputParser() }); let message = ''; diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index 4b564da1..412334c0 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -2,6 +2,8 @@ import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } import type { BoltArtifactData } from '~/types/artifact'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; +import { AgentOutputParser } from '../agents/agent-output-parser'; +import { agentRegistry } from '~/utils/agentFactory'; const ARTIFACT_TAG_OPEN = ' string; export interface StreamingMessageParserOptions { callbacks?: ParserCallbacks; artifactElement?: ElementFactory; + agentOutputParser: AgentOutputParser; + } interface MessageState { position: number; insideArtifact: boolean; + insideToolCall: boolean; insideAction: boolean; currentArtifact?: BoltArtifactData; currentAction: BoltActionData; @@ -54,16 +59,18 @@ interface MessageState { export class StreamingMessageParser { #messages = new Map(); - - constructor(private _options: StreamingMessageParserOptions = {}) { } + constructor(private _options: StreamingMessageParserOptions = {agentOutputParser:new AgentOutputParser()}) { + + } parse(messageId: string, input: string) { let state = this.#messages.get(messageId); - + const TOOL_CALL_TAG_OPEN = this._options.agentOutputParser.getToolCallTagOpen(); if (!state) { state = { position: 0, insideAction: false, + insideToolCall: false, insideArtifact: false, currentAction: { content: '' }, actionId: 0, @@ -166,15 +173,33 @@ export class StreamingMessageParser { state.currentArtifact = undefined; i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length; + } else { break; } } - } else if (input[i] === '<' && input[i + 1] !== '/') { + } + else if (state.insideToolCall) { + let { cursor, event } = this._options.agentOutputParser.parse(messageId, input.slice(state.position)); + console.log({ cursor, event, input, state }) + + if (event && event.type == 'toolCallComplete') { + state.position += cursor.position + 1; + i = state.position; + state.insideToolCall = false; + + const artifactFactory = this._options.artifactElement ?? createArtifactElement; + output += artifactFactory({ messageId }) || ''; + break; + } + + break + + } + else if (input[i] === '<' && input[i + 1] !== '/') { let j = i; let potentialTag = ''; - - while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) { + while (j < input.length && (potentialTag.length < ARTIFACT_TAG_OPEN.length||potentialTag.length>; export type WorkbenchViewType = 'code' | 'preview'; + export class WorkbenchStore { #previewsStore = new PreviewsStore(webcontainer); #filesStore = new FilesStore(webcontainer); #editorStore = new EditorStore(this.#filesStore); #terminalStore = new TerminalStore(webcontainer); + addChatMessage?: (message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions) => Promise artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); @@ -41,8 +44,6 @@ export class WorkbenchStore { unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); artifactIdList: string[] = []; - #boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined; - constructor() { if (import.meta.hot) { import.meta.hot.data.artifacts = this.artifacts; @@ -258,7 +259,6 @@ export class WorkbenchStore { async addAction(data: ActionCallbackData) { const { messageId } = data; - const artifact = this.#getArtifact(messageId); if (!artifact) { @@ -276,6 +276,9 @@ export class WorkbenchStore { if (!artifact) { unreachable('Artifact not found'); } + let action=artifact.runner.actions.get()[data.actionId]; + artifact.runner.actions.setKey(data.actionId, { ...action,...data.action}); + if (data.action.type === 'file') { let wc = await webcontainer const fullPath = nodePath.join(wc.workdir, data.action.filePath); @@ -286,18 +289,40 @@ export class WorkbenchStore { this.currentView.set('code'); } const doc = this.#editorStore.documents.get()[fullPath]; + if (!doc) { await artifact.runner.runAction(data, isStreaming); } - + this.#editorStore.updateFile(fullPath, data.action.content); - + if (!isStreaming) { this.resetCurrentDocument(); - await artifact.runner.runAction(data); + await artifact.runner.runAction(data) } - } else { - artifact.runner.runAction(data); + } + else if (data.action.type === 'tool') { + await new Promise((resolve=>{ + artifact.runner.runAction(data,false,async ()=>{ + resolve(); + }); + })); + let actionState = this.artifacts.get()[data.messageId].runner.actions.get()[data.actionId] + if (actionState.type !== 'tool') { + return; + } + if (actionState.status !== 'complete') { + return; + } + let result = actionState.result + await this.addChatMessage?.({ + role:'user', + content:`Below is the result of tool execution.\n\n${JSON.stringify(result)}`, + annotations:["toolResponse"] + }); + } + else { + await artifact.runner.runAction(data); } } diff --git a/app/lib/tools/base-tool.ts b/app/lib/tools/base-tool.ts new file mode 100644 index 00000000..01a369a7 --- /dev/null +++ b/app/lib/tools/base-tool.ts @@ -0,0 +1,11 @@ +import type { WebContainer } from "@webcontainer/api"; +import type { ToolConfig } from "~/types/tools"; + +export abstract class BaseTool { + protected webcontainer: Promise; + constructor(webcontainerPromise: Promise) { + this.webcontainer = webcontainerPromise; + } + /** Function to execute the tool with given arguments */ + abstract execute(args: { [key: string]: string }):Promise; +} \ No newline at end of file diff --git a/app/lib/tools/project-template-import.ts b/app/lib/tools/project-template-import.ts new file mode 100644 index 00000000..53f4a768 --- /dev/null +++ b/app/lib/tools/project-template-import.ts @@ -0,0 +1,227 @@ +import { TEMPLATE_LIST } from '~/utils/constants'; +import * as nodePath from 'node:path'; +import { matchPatterns } from '~/utils/matchPatterns'; +import { BaseTool } from './base-tool'; +import type { WebContainer } from '@webcontainer/api'; + +export class ProjectTemplateImportTool extends BaseTool { + constructor(webcontainerPromise: Promise ) { + super(webcontainerPromise); + } + async execute(args: { [key: string]: string; }): Promise { + if (args.id == 'blank') { + return this.generateFormattedResult(`template imported successfully`, ` + We are starting from scratch. and black project + `) + } + let template = TEMPLATE_LIST.find(t => t.name === args.id); + if (!template) { + console.log('template not found', args.template); + + return 'template not found'; + } + try { + let files = await this.getGitHubRepoContent(template.githubRepo); + let webcontainer = await this.webcontainer; + + for (const file of files) { + let fullPath = nodePath.join(webcontainer.workdir, file.path); + let folder = nodePath.dirname(file.path); + // remove trailing slashes + folder = folder.replace(/\/+$/g, ''); + if (folder !== '.') { + try { + await webcontainer.fs.mkdir(folder, { recursive: true }); + console.debug('Created folder', folder); + } catch (error) { + console.error('Failed to create folder\n\n', error); + } + } + console.log("Writing to file", fullPath); + + await webcontainer.fs.writeFile(file.path, file.content); + } + + let filteredFiles = files; + + // ignoring common unwanted files + // exclude .git + filteredFiles = filteredFiles.filter(x => x.path.startsWith(".git") == false) + // exclude lock files + let comminLockFiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"] + filteredFiles = filteredFiles.filter(x => comminLockFiles.includes(x.name) == false) + // exclude .bolt + filteredFiles = filteredFiles.filter(x => x.path.startsWith(".bolt") == false) + + + // check for ignore file in .bolt folder + let templateIgnoreFile = files.find(x => x.path.startsWith(".bolt") && x.name == "ignore") + if (templateIgnoreFile) { + // redacting files specified in ignore file + let ignorepatterns = templateIgnoreFile.content.split("\n").map(x => x.trim()) + filteredFiles = filteredFiles.filter(x => matchPatterns(x.path, ignorepatterns) == false) + let redactedFiles = filteredFiles.filter(x => matchPatterns(x.path, ignorepatterns)) + redactedFiles = redactedFiles.map(x => { + return { + ...x, + content: "redacted" + } + }) + filteredFiles = [ + ...filteredFiles, + ...redactedFiles + ] + } + + let templatePromptFile = files.filter(x => x.path.startsWith(".bolt")).find(x => x.name == 'prompt') + + return this.generateFormattedResult(`Project Scaffolding Is Complete`, ` + # Project Scaffolding Is Complete + +# Project Context + +## Imported Files +${ JSON.stringify( + filteredFiles + // .map(x => x.path) + , null, 2) } + + +${templatePromptFile ? ` +## User Requirements +${templatePromptFile.content} +` : ''} + +# Development Process (In Strict Order) + +1. STEP 1: Dependencies Installation + npm install # MANDATORY - Must be run first + +2. STEP 2: Planning Phase + - Create implementation plan + - List required files + - Document dependencies + +3. STEP 3: Implementation + - Follow architecture requirements + - Create/modify files + - Test functionality + +## Architecture Requirements +- Break down functionality into modular components +- Maximum file size: 500 lines of code +- Follow single responsibility principle +- Create reusable utility functions where appropriate + +## Project Constraints +1. Template Usage + - Do NOT import additional templates + - Existing template is pre-imported and should be used + +2. Code Organization + - Create separate files for distinct functionalities + - Use meaningful file and function names + - Implement proper error handling + +3. Boilerplate Protection + - Do NOT modify boilerplate files + - Exception: Only if absolutely necessary for core functionality + - Document any required boilerplate changes + +4. Dependencies + - All dependencies must be installed via npm/yarn + - List all required dependencies with versions + - Include installation commands in documentation + +## Expected Deliverables +1. Implementation plan +2. List of files to be created/modified +3. Dependencies list with installation commands +4. Code implementation +5. Basic usage documentation + +## Important Notes +- These guidelines are mandatory and non-negotiable +- Provide clear comments for complex logic +- Include error handling for critical operations +- Document any assumptions made during implementation + `) + } catch (error) { + console.error('error importing template', error); + return 'error fetching template'; + } + } + private async getGitHubRepoContent(repoName: string, path: string = ''): Promise<{ name: string, path: string, content: string }[]> { + + const baseUrl = 'https://api.github.com'; + + try { + // Fetch contents of the path + const response = await fetch(`${baseUrl}/repos/${repoName}/contents/${path}`, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + // Add your GitHub token if needed + 'Authorization': 'token ' + import.meta.env.VITE_GITHUB_ACCESS_TOKEN + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: any = await response.json(); + + // If it's a single file, return its content + if (!Array.isArray(data)) { + if (data.type === 'file') { + // If it's a file, get its content + const content = atob(data.content); // Decode base64 content + return [{ + name: data.name, + path: data.path, + content: content + }]; + } + } + + // Process directory contents recursively + const contents = await Promise.all( + data.map(async (item: any) => { + if (item.type === 'dir') { + // Recursively get contents of subdirectories + return await this.getGitHubRepoContent(repoName, item.path); + } else if (item.type === 'file') { + // Fetch file content + const fileResponse = await fetch(item.url, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': 'token ' + import.meta.env.VITE_GITHUB_ACCESS_TOKEN + } + }); + const fileData: any = await fileResponse.json(); + const content = atob(fileData.content); // Decode base64 content + + return [{ + name: item.name, + path: item.path, + content: content + }]; + } + }) + ); + + // Flatten the array of contents + return contents.flat(); + } catch (error) { + console.error('Error fetching repo contents:', error); + throw error; + } + } + private generateFormattedResult(uiResult: string, aiResult?: string) { + return ` + ${uiResult} + --- + ${aiResult || ""} + ` + } +} \ No newline at end of file diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index 47666c70..042917b2 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -30,8 +30,9 @@ function parseCookies(cookieHeader) { } async function chatAction({ context, request }: ActionFunctionArgs) { - const { messages } = await request.json<{ - messages: Messages + const { messages,toolConfig } = await request.json<{ + messages: Messages, + toolConfig:any }>(); const cookieHeader = request.headers.get("Cookie"); @@ -43,7 +44,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) { try { const options: StreamingOptions = { - toolChoice: 'none', apiKeys, onFinish: async ({ text: content, finishReason }) => { if (finishReason !== 'length') { @@ -67,7 +67,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) { }, }; - const result = await streamText(messages, context.cloudflare.env, options, apiKeys); + const result = await streamText(messages, context.cloudflare.env, options, apiKeys,toolConfig); stream.switchSource(result.toAIStream()); diff --git a/app/types/actions.ts b/app/types/actions.ts index 08c1f39a..9010cd79 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -17,6 +17,14 @@ export interface StartAction extends BaseAction { type: 'start'; } -export type BoltAction = FileAction | ShellAction | StartAction; +export interface ToolAction extends BaseAction { + type: 'tool'; + agentId: string; + toolName: string; + parameters?: Record; + result?: string; +} + +export type BoltAction = FileAction | ShellAction | StartAction | ToolAction; export type BoltActionData = BoltAction | BaseAction; diff --git a/app/types/agent.ts b/app/types/agent.ts new file mode 100644 index 00000000..06677d46 --- /dev/null +++ b/app/types/agent.ts @@ -0,0 +1,34 @@ +import type { ToolConfig } from "./tools"; + +/** + * Configuration interface for an agent's capabilities and behavior + * @interface AgentConfig + */ +export interface AgentConfig { + /** Name of the agent */ + name: string; + /** Unique identifier for the agent */ + agentId: string; + /** Description of the agent's role and capabilities */ + description: string; + /** The primary purpose or objective of the agent */ + purpose: string; + /** Array of tools available to the agent */ + tools: ToolConfig[]; + /** Categorized rules that govern the agent's behavior */ + rules: { + /** Category name for a group of rules */ + category: string; + /** Array of rule statements in this category */ + items: string[]; + }[]; + /** Optional template variables that can be used in prompt generation */ + templateVariables?: { + /** Name of the template variable */ + name: string; + /** Data type of the template variable */ + type: string; + /** Description of the template variable's purpose */ + description: string; + }[]; +} diff --git a/app/types/tools.ts b/app/types/tools.ts new file mode 100644 index 00000000..3efa1618 --- /dev/null +++ b/app/types/tools.ts @@ -0,0 +1,33 @@ +/** + * Configuration interface for a parameter in a tool + * @interface ParameterConfig + */ +export interface ParameterConfig { + /** The name of the parameter */ + name: string; + /** The data type of the parameter */ + type: string; + /** Description explaining the parameter's purpose and usage */ + description: string; + /** Example value for the parameter */ + example: string; +} + +/** + * Configuration interface for a tool that can be used by an agent + * @interface ToolConfig + */ +export interface ToolConfig { + /** Unique identifier for the tool */ + name: string; + /** Display name for the tool */ + label: string; + /** The type of tool - either 'action' for command execution or 'response' responding with text to user */ + type: 'action' | 'response'; + /** Detailed description of what the tool does */ + description: string; + /** Array of parameters required by the tool */ + parameters: ParameterConfig[]; + /** Function to execute the tool with given arguments */ + execute: (args: { [key: string]: string }) => Promise; +} \ No newline at end of file diff --git a/app/utils/agentFactory.ts b/app/utils/agentFactory.ts new file mode 100644 index 00000000..6e9c0b24 --- /dev/null +++ b/app/utils/agentFactory.ts @@ -0,0 +1,228 @@ +import { Agent } from "~/lib/agents/agent-generator"; +import { TEMPLATE_LIST } from "./constants"; +import { webcontainer } from "~/lib/webcontainer"; + + + + +let codeTemplateAgent = new Agent({ + agentId: 'project-scaffold', + name: 'Project Scaffolding Agent', + description: 'Initializes new projects by selecting and applying appropriate project templates', + purpose: `Initialize new projects only at the start of development. + This agent should only be selected for queries that needs to setup a framework/codebase, + NOT for general coding questions or existing projects. + MUST USE THIS ONLY AT THE START OF DEVELOPMENT. + `, + rules: [ + { + category: 'template-selection', + items: [ + 'Analyze project requirements before selecting template', + 'Default to blank template if no specific requirements match', + 'Consider tech stack preferences if mentioned', + 'Validate template compatibility with user requirements', + 'MUST select blank template if no specific requirements match', + ] + } + ], + tools: [ + { + name: 'selectTemplate', + type: 'action', + description: 'Selects the most appropriate project template based on requirements', + label: 'Import Project Template', + parameters: [ + { + name: 'id', + type: 'string', + description: 'mulst be one of the available templates', + example: `${TEMPLATE_LIST[0].name}` + } + ], + execute: async (args) => { + // import the project template import tool + // this has to be dynamic since it's also imported in server which does not have the webcontainer + const {ProjectTemplateImportTool} = await import('../lib/tools/project-template-import'); + let projectImporter=new ProjectTemplateImportTool(webcontainer); + let response=await projectImporter.execute(args); + return response; + } + }, + ], + templateVariables: [ + { + name: 'availableTemplates', + type: 'array', + description: 'List of available project templates with their specifications', + }, + ] +}) +codeTemplateAgent.setTemplateValue('availableTemplates', TEMPLATE_LIST.map(t => { + return { + templateId: t.name, + name: t.label, + } +})) + +let availableAgents: Agent[] = [codeTemplateAgent] + +const coordinatorAgent = new Agent({ + agentId: 'coordinator', + name: 'Coordinator Agent', + description: 'A coordinator agent that routes queries to appropriate specialized agents and manages clarifications', + purpose: 'Route user queries to the most suitable agent and request clarification when needed', + rules: [ + { + category: 'routing', + items: [ + 'Always analyze user query intent before routing', + 'Route to the most specialized agent for the task', + // 'Ask for clarification if query intent is ambiguous', + 'Only route to available agents in the provided agent list', + 'MUST ROUTE TO CODE SCAFFOLDING AGENT IF THIS IS A NEW PROJECT OR START OF A NEW DEVELOPMENT', + 'DO NOT ROUTE TO OTHER AGENT IF PROJECT IS INITIALIZED AND CODE GENERATION CAN SOLVE THE PROBLEM', + ] + }, + // { + // category: 'clarification', + // items: [ + // 'Ask specific questions related to ambiguous parts of the query', + // 'Provide context about why clarification is needed', + // 'Keep clarification questions concise and focused' + // ] + // }, + { + category: 'coder generation agent', + items: [ + 'MUST be the default handler for ALL requests', + 'MUST attempt to solve through code generation first', + 'Handles ALL tasks that can be solved through code generation:', + ' - Writing new code', + ' - Modifying existing code', + ' - Generating configurations', + ' - Creating scripts', + ' - Defining workflows', + ' - Writing documentation', + ' - Installing dependencies', + ' - Starting development server', + 'MUST ONLY reject requests when:', + ' - Task explicitly requires system/external actions that cannot be solved with code', + ' - Task is not solvable through code', + ] + }, + + ], + tools: [ + { + name: 'routeToAgent', + type: 'action', + description: 'Routes the current query to the most appropriate agent', + label: 'Route to Agent', + parameters: [ + { + name: 'agentId', + type: 'string', + description: 'ID of the agent to route to', + example: 'math-agent' + }, + { + name: 'query', + type: 'string', + description: 'The original or clarified user query', + example: 'What is the square root of 16?' + }, + { + name: 'confidence', + type: 'number', + description: 'Confidence score for this routing decision (0-1)', + example: '0.95' + } + ], + execute: async (args) => { + const selectedAgent = availableAgents.find(agent => agent.getConfig().agentId === args.agentId); + if (!selectedAgent) { + return JSON.stringify({ + success: false, + error: 'Agent not found' + }); + } + return JSON.stringify({ + success: true, + routedTo: args.agentId, + confidence: args.confidence + }); + } + }, + { + name: 'routeToDefaultAgent', + type: 'action', + description: 'Routes the current query to the default agent', + label: 'Route to Default Agent', + parameters: [ + { + name: 'confidence', + type: 'number', + description: 'Confidence score for this routing decision (0-1)', + example: '0.95' + } + ], + execute: async (args) => { + return JSON.stringify({ + success: true, + confidence: args.confidence + }); + } + }, + // { + // name: 'requestClarification', + // type: 'action', + // description: 'Requests clarification from the user when query intent is unclear', + // label: 'Request Clarification', + // parameters: [ + // { + // name: 'question', + // type: 'string', + // description: 'The clarifying question to ask the user', + // example: 'Could you specify whether you need mathematical or statistical analysis?' + // } + // ], + // execute: async (args) => { + // return args.question; + // } + // } + ], + templateVariables: [ + { + name: 'availableAgents', + type: 'array', + description: 'List of available agents with their capabilities and descriptions' + }, + { + name: 'confidenceThreshold', + type: 'number', + description: 'Minimum confidence threshold required for routing without clarification' + } + ] +}); + +// Set template values +coordinatorAgent.setTemplateValue('availableAgents', availableAgents.map(agent => { + return JSON.stringify({ + agentId: agent.getConfig().agentId, + name: agent.getConfig().name, + description: agent.getConfig().description, + purpose: agent.getConfig().purpose, + capabilities: agent.getTools().map(t => t.label).join(', ') + }, null, 2) +}).join('\n')); +coordinatorAgent.setTemplateValue('confidenceThreshold', 0.8); + +export const prebuiltAgents: { [key: string]: Agent } = { + coordinatorAgent, + codeTemplateAgent, +} +export const agentRegistry: { [key: string]: Agent } = Object.keys(prebuiltAgents).reduce((acc, key) => { + acc[prebuiltAgents[key].getConfig().agentId] = prebuiltAgents[key]; + return acc; +}, {} as { [key: string]: Agent }) \ No newline at end of file diff --git a/app/utils/constants.ts b/app/utils/constants.ts index fbcf226e..620a1f47 100644 --- a/app/utils/constants.ts +++ b/app/utils/constants.ts @@ -1,4 +1,4 @@ -import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types'; +import type { ModelInfo, OllamaApiResponse, OllamaModel, TemplateInfo } from './types'; import type { ProviderInfo } from '~/types/model'; export const WORK_DIR_NAME = 'project'; @@ -117,6 +117,12 @@ const PROVIDER_LIST: ProviderInfo[] = [ } ]; +const codeTemplates: TemplateInfo[] = [ + { name: 'vite-react-tailwind-ts', label: 'Vite React TS', githubRepo: 'thecodacus/vite-react-ts-template' }, + { name: 'blank', label: 'Start from scratch', githubRepo: '' }, +]; +export let TEMPLATE_LIST: TemplateInfo[] = [...codeTemplates]; + export const DEFAULT_PROVIDER = PROVIDER_LIST[0]; const staticModels: ModelInfo[] = PROVIDER_LIST.map(p => p.staticModels).flat(); diff --git a/app/utils/matchPatterns.ts b/app/utils/matchPatterns.ts new file mode 100644 index 00000000..9c80c1c2 --- /dev/null +++ b/app/utils/matchPatterns.ts @@ -0,0 +1,28 @@ +/** + * A utility to match glob patterns in the browser + * Supports basic glob features like * and ** + */ +export function isMatch(path: string, pattern: string): boolean { + // Convert glob pattern to regex + const regex = pattern + // Escape special regex characters except * and / + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + // Replace ** with special marker + .replace(/\*\*/g, '{{GLOBSTAR}}') + // Replace single * with non-slash matcher + .replace(/\*/g, '[^/]*') + // Replace globstar marker with proper pattern + .replace(/{{GLOBSTAR}}/g, '.*') + // Anchor pattern to full path match + .replace(/^/, '^') + .replace(/$/, '$'); + + return new RegExp(regex).test(path); +} + +/** + * Match multiple patterns against a path + */ +export function matchPatterns(path: string, patterns: string[]): boolean { + return patterns.some(pattern => isMatch(path, pattern)); +} \ No newline at end of file diff --git a/app/utils/types.ts b/app/utils/types.ts index a5f9fc18..078c8f47 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -27,6 +27,21 @@ export interface ModelInfo { provider: string; } +export interface TemplateInfo { + name: string; + label: string; + githubRepo: string; +} +export interface IToolsConfig { + enabled: boolean; + //this section will be usefull for future features + config: Record +} + export interface ProviderInfo { staticModels: ModelInfo[], name: string, diff --git a/package.json b/package.json index ce8e95d0..dcd40965 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@openrouter/ai-sdk-provider": "^0.0.5", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-switch": "^1.1.1", "@remix-run/cloudflare": "^2.10.2", "@remix-run/cloudflare-pages": "^2.10.2", "@remix-run/react": "^2.10.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82e14c1e..b026fd8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@remix-run/cloudflare': specifier: ^2.10.2 version: 2.10.2(@cloudflare/workers-types@4.20240620.0)(typescript@5.5.2) @@ -1377,6 +1380,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.1': resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} peerDependencies: @@ -1543,6 +1555,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.1': + resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1579,6 +1604,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -6712,6 +6746,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-context@1.1.1(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-dialog@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -6889,6 +6929,21 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6915,6 +6970,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.0