UNPKG

@obayd/agentic

Version:

A powerful agent framework for LLMs.

731 lines (651 loc) 38.7 kB
// src/Conversation.js import { FUNCTION_PROMPT, TOOLPACKS_PROMPT } from './prompts.js'; import { makeid, parseAttributes, normalizeToolResult } from './utils.js'; import { Tool } from './Tool.js'; import { Toolpack } from './Toolpack.js'; export class Conversation { #llmCallback; #contentDefinition = []; #options = {}; #invokeArgs = []; messages = []; enabledToolpacks = new Set(); #activeToolsMap = new Map(); #allDefinedToolpacksMap = new Map(); #functionTag = "fn"; #resultTag = "rs"; #functionCompleteRegex = null; #functionStartRegex = null; #functionEndRegexStr = ""; // Store string for dynamic regex creation constructor(llmCallback, options = {}) { if ( typeof llmCallback !== "function" || llmCallback.constructor.name !== "AsyncGeneratorFunction" ) { throw new Error("llmCallback must be an async generator function."); } this.#llmCallback = llmCallback; this.#options = { ...options }; this.enabledToolpacks = new Set( this.#options.initialEnabledToolpacks || [] ); const tagSuffixLength = 6; this.#functionTag = `fn_${makeid(tagSuffixLength)}`; this.#resultTag = `rs_${makeid(tagSuffixLength)}`; this.#functionEndRegexStr = `<\\/${this.#functionTag}>`; this.#functionCompleteRegex = new RegExp( `<${ this.#functionTag }\\s+callId="([^"]+)"\\s+name="([a-zA-Z0-9_]+)"\\s*([^>]*?)>([\\s\\S]*?)${ this.#functionEndRegexStr }>`, "g" ); this.#functionStartRegex = new RegExp( `<${ this.#functionTag }\\s+callId="([^"]+)"\\s+name="([a-zA-Z0-9_]+)"\\s*([^>]*?)>`, "" // No 'g' flag ); } content(definition) { if (!Array.isArray(definition)) throw new Error("Content definition must be an array."); this.#contentDefinition = definition; if (definition.some((item) => item instanceof Toolpack)) { this.#addEnableToolpackTool(); } return this; } #addEnableToolpackTool() { const alreadyHasEnableTool = this.#contentDefinition.some( (item) => item instanceof Tool && item.name === "enable_toolpack" ); if (!alreadyHasEnableTool) { const enableTool = Tool.make("enable_toolpack") .description( "Enables a toolpack, making its functions available for use in subsequent turns." ) .param("pack_name", "The exact name of the toolpack to enable.", { type: "string", required: true, }) .action(async (params, conversationInstance) => { const packName = params?.pack_name; if (!packName) return { error: "Missing required parameter 'pack_name'." }; const pack = conversationInstance.#allDefinedToolpacksMap.get(packName); if (!pack) return { error: `Toolpack '${packName}' not found.` }; if (conversationInstance.enabledToolpacks.has(packName)) { return { content: `Toolpack '${packName}' is already enabled.` }; } conversationInstance.enabledToolpacks.add(packName); console.log(`[Tool] Toolpack '${packName}' enabled.`); return { content: `Toolpack '${packName}' enabled successfully. You can now use its tools.`, }; }); this.#contentDefinition.push(enableTool); } } async #buildConversationState() { const activeTools = []; const allDefinedToolpacks = new Map(); const baseSystemPromptParts = []; const processItem = async (item) => { if (typeof item === "string") { baseSystemPromptParts.push({ type: "text", text: item }); } else if (item instanceof Tool) { activeTools.push(item); } else if (item instanceof Toolpack) { if (!allDefinedToolpacks.has(item.name)) allDefinedToolpacks.set(item.name, item); else console.warn(`Duplicate toolpack definition: ${item.name}.`); } else if (typeof item === "function") { try { const result = await item(this, ...this.#invokeArgs); if (Array.isArray(result)) { for (const resItem of result) { if (resItem) await processItem(resItem); // Recurse for array results } } else if (result) { await processItem(result); // Recurse for single result } } catch (e) { console.error("Error executing content definition function:", e); baseSystemPromptParts.push({ type: "text", text: "[Error Generating Dynamic Content]", }); } } else if (typeof item === "object" && item !== null && item.type) { // Assume it's a valid content part object (e.g., { type: 'image', ... }) baseSystemPromptParts.push(item); } else if (item !== null && item !== undefined){ // Avoid warning for null/undefined results from functions console.warn("Unsupported content definition element:", item); } }; // Process all items defined in content() for (const item of this.#contentDefinition) { await processItem(item); } const toolpackDescriptions = []; allDefinedToolpacks.forEach((tp) => { const isEnabled = this.enabledToolpacks.has(tp.name); toolpackDescriptions.push(tp.buildPromptString(isEnabled)); if (isEnabled) activeTools.push(...tp.getTools()); }); const finalActiveToolsMap = new Map(); activeTools.forEach((tool) => { if (!finalActiveToolsMap.has(tool.name)) { finalActiveToolsMap.set(tool.name, tool); } else { // Handle potential duplicates (e.g., tool defined directly and via enabled pack) // Prioritize the directly defined one or the first encountered one? // Current logic: First one encountered wins (direct definition processed first usually). // console.warn(`Duplicate active tool detected: ${tool.name}. Using first encountered definition.`); } }); // --- Assemble the final system prompt content --- const systemPromptContent = [...baseSystemPromptParts]; // Start with base text/objects if (finalActiveToolsMap.size > 0) { const functionsString = Array.from(finalActiveToolsMap.values()) .map((tool) => tool.buildPromptString()) .join("\n\n"); const funcPrompt = FUNCTION_PROMPT.replaceAll( "%FUNCTAG%", this.#functionTag ).replaceAll( "%RESULTTAG%", this.#resultTag ).replace( "%FUNCTIONS%", functionsString.trim() || "No functions available." ); systemPromptContent.push({ type: "text", text: funcPrompt }); } if (toolpackDescriptions.length > 0) { const tpPrompt = TOOLPACKS_PROMPT.replaceAll( "%FUNCTAG%", this.#functionTag ).replaceAll( "%RESULTTAG%", this.#resultTag ).replace( "%TOOLPACKS%", toolpackDescriptions.join("\n").trim() || "No toolpacks defined." ); systemPromptContent.push({ type: "text", text: tpPrompt }); } // --- Update internal state --- this.#activeToolsMap = finalActiveToolsMap; this.#allDefinedToolpacksMap = allDefinedToolpacks; // Store all *defined* packs return systemPromptContent; // Return the structured content array } #prepareMessagesForLLM(systemContent) { const history = this.messages; const llmMessages = []; // Add the system message first, with its structured content llmMessages.push({ role: "system", content: systemContent }); for (const msg of history) { switch (msg.role) { case "user": case "assistant": // Pass user/assistant messages directly llmMessages.push({ role: msg.role, content: msg.content }); break; case "tool": // Format tool result message for the LLM if (msg.result === null || msg.result === undefined) { // This might happen if processing failed before result was attached console.warn(`Tool message for callId ${msg.callId} has null/undefined result.`); continue; // Skip sending this malformed message back } const status = msg.error ? "error" : "success"; let resultString; try { // Send the raw result back, not the normalized 'content' field used for history resultString = JSON.stringify(msg.result); } catch (e) { console.error(`Failed to stringify tool result for callId ${msg.callId}:`, msg.result); resultString = JSON.stringify({ error: "Failed to serialize tool result for LLM.", }); } const resultTag = `<${this.#resultTag} callId="${msg.callId}" status="${status}">${resultString}</${this.#resultTag}>`; // Send tool results back as 'user' role message containing the result tag llmMessages.push({ role: "user", content: resultTag }); break; case "error": // Send internal error messages as user messages for context llmMessages.push({ role: "user", content: `[System Error: ${msg.content}]`, }); break; // 'system' role is only added at the beginning } } return llmMessages; } async *#invokeAndProcess() { const systemContent = await this.#buildConversationState(); const messagesForLLM = this.#prepareMessagesForLLM(systemContent); let currentBuffer = ""; let ongoingToolCalls = []; // Stores promises for pending tool executions let generatingToolCall = null; // State: { callId, name, params, raw, startTagContent } let accumulatedAssistantContent = ""; // Dynamic end tag regex (case-insensitive) const functionEndRegex = new RegExp(this.#functionEndRegexStr, "i"); try { // Assuming llmCallback returns an async generator yielding string chunks const llmStream = this.#llmCallback(messagesForLLM, { stream: true }); for await (const chunk of llmStream) { if (typeof chunk !== "string") { console.warn("LLM stream yielded non-string chunk:", chunk); continue; }; currentBuffer += chunk; // --- State: Currently Parsing/Generating a Tool Call --- if (generatingToolCall) { const endMatch = currentBuffer.match(functionEndRegex); if (endMatch) { // Found the end tag for the current tool call const endIndex = endMatch.index; // Index where '</fn_...>' starts const startTagEndIndex = currentBuffer.indexOf(generatingToolCall.startTagContent) + generatingToolCall.startTagContent.length; let rawContent = ""; if (endIndex >= 0 && endIndex > startTagEndIndex) { rawContent = currentBuffer.substring(startTagEndIndex, endIndex); } // Yield final raw chunk if needed const newRawChunk = rawContent.substring(generatingToolCall.raw.length); if (newRawChunk) { generatingToolCall.raw = rawContent; // Update total raw *before* yielding final generating event yield { type: "tool.generating", role: "tool.generating", // Maintain consistent role property callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, rawChunk: newRawChunk, raw: generatingToolCall.raw, // The complete raw content now }; } // Yield 'tool.calling' event and add to history/promises const finalCallId = generatingToolCall.callId; const finalParams = generatingToolCall.params; const finalRaw = generatingToolCall.raw || null; // Use null if empty string const finalName = generatingToolCall.name; yield { type: "tool.calling", role: "tool.calling", callId: finalCallId, name: finalName, params: finalParams, raw: finalRaw, }; const toolMessage = { role: "tool", callId: finalCallId, name: finalName, params: finalParams, raw: finalRaw, result: null, // Placeholder for result content: null, // Placeholder for normalized content }; this.messages.push(toolMessage); const tool = this.#activeToolsMap.get(finalName); const toolPromise = tool ? tool.call(finalParams, finalRaw, [this, ...this.#invokeArgs]) // Pass conversation instance + invokeArgs : Promise.resolve({ error: `Function '${finalName}' not found or not enabled.` }); // Handle missing tool immediately ongoingToolCalls.push( toolPromise .then((result) => ({ callId: finalCallId, result })) .catch((error) => { // Catch errors *during* the tool.call promise execution console.error(`Critical error during tool.call for ${finalName} (${finalCallId}):`, error); return { callId: finalCallId, result: { error: `Tool execution critically failed: ${error.message}` } }; }) ); // Reset state and buffer currentBuffer = currentBuffer.substring(endIndex + endMatch[0].length); generatingToolCall = null; // IMPORTANT: After finding a tool call, restart processing the remaining buffer // This handles cases where assistant text follows a tool call immediately. continue; // Go to the next iteration of the main loop } else { // End tag not found yet. Check if new raw content has arrived. // Find the start tag within the current buffer (it should be at index 0 if state is correct) const startTagIndexInBuffer = currentBuffer.indexOf(generatingToolCall.startTagContent); if (startTagIndexInBuffer === 0) { // Verify it's still at the start const potentialRaw = currentBuffer.substring(generatingToolCall.startTagContent.length); if (potentialRaw.length > generatingToolCall.raw.length) { const newRawChunk = potentialRaw.substring(generatingToolCall.raw.length); // Yield the generating event, but DON'T update generatingToolCall.raw yet. // We only update the final raw content once the end tag is confirmed. yield { type: "tool.generating", role: "tool.generating", callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, rawChunk: newRawChunk, raw: generatingToolCall.raw + newRawChunk, // Show projected total raw }; } } else { // This case should ideally not happen if logic is sound, but indicates a parsing issue. console.warn("[WARN] Start tag no longer at buffer start while generating tool call. Buffer:", currentBuffer); // Potentially treat buffer as text and reset generatingToolCall? Or just wait? // For now, we wait, assuming more chunks might resolve it. } } } // --- State: Not Currently Parsing a Tool Call --- else if (currentBuffer) { // Use the non-global regex here const startMatch = currentBuffer.match(this.#functionStartRegex); if (startMatch) { // Found a potential start tag const startIndex = startMatch.index; const fullStartTag = startMatch[0]; const callId = startMatch[1]; const toolName = startMatch[2]; const attrs = startMatch[3]; // --- Yield preceding text --- if (startIndex > 0) { const precedingText = currentBuffer.substring(0, startIndex); yield { type: "assistant", role: "assistant", content: precedingText }; accumulatedAssistantContent += precedingText; } // --- Start processing the tool call --- let params = {}; try { params = parseAttributes(attrs); } catch (e) { console.error(`[ERROR] Failed to parse attributes for ${toolName}(${callId}): ${attrs}`, e); // Treat the invalid tag as text? Or yield an error? Yielding as text for now. const invalidTagAsText = currentBuffer.substring(startIndex, startIndex + fullStartTag.length); yield { type: "assistant", role: "assistant", content: invalidTagAsText }; accumulatedAssistantContent += invalidTagAsText; currentBuffer = currentBuffer.substring(startIndex + fullStartTag.length); continue; // Process rest of buffer } // --- Initialize generating state --- generatingToolCall = { callId, name: toolName, params, raw: "", // Raw content starts empty startTagContent: fullStartTag, }; // Adjust buffer: Remove preceding text, keep the tag and the rest currentBuffer = currentBuffer.substring(startIndex); // --- Check if the end tag is *already* in the buffer --- // Create a temporary buffer *after* the start tag for end tag checking const contentAfterStartTag = currentBuffer.substring(fullStartTag.length); const endMatchImmediate = contentAfterStartTag.match(functionEndRegex); if (endMatchImmediate) { // Complete tool call found within the current chunk! const rawContent = contentAfterStartTag.substring(0, endMatchImmediate.index); generatingToolCall.raw = rawContent; // Set final raw content // Yield 'generating' if there was raw content if (rawContent) { yield { type: "tool.generating", role: "tool.generating", callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, rawChunk: rawContent, // The entire raw content is the chunk here raw: rawContent, }; } // Yield 'calling' event yield { type: "tool.calling", role: "tool.calling", callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, raw: generatingToolCall.raw || null, // Use null if empty }; // Add to history and promises const toolMessage = { role: "tool", callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, raw: generatingToolCall.raw || null, result: null, content: null, }; this.messages.push(toolMessage); const tool = this.#activeToolsMap.get(generatingToolCall.name); const toolPromise = tool ? tool.call(generatingToolCall.params, generatingToolCall.raw || null, [this, ...this.#invokeArgs]) : Promise.resolve({ error: `Function '${generatingToolCall.name}' not found or not enabled.` }); ongoingToolCalls.push( toolPromise .then((result) => ({ callId: generatingToolCall.callId, result })) .catch((error) => { console.error(`Critical error during tool.call for ${generatingToolCall.name} (${generatingToolCall.callId}):`, error); return { callId: generatingToolCall.callId, result: { error: `Tool execution critically failed: ${error.message}` } }; }) ); // Update buffer: remove the entire tool call block currentBuffer = contentAfterStartTag.substring(endMatchImmediate.index + endMatchImmediate[0].length); generatingToolCall = null; // Reset state // Restart processing the remaining buffer continue; } else { // Start tag found, but no end tag *yet*. // The buffer currently holds: <fn...> potentially_partial_raw_content const potentialRawChunk = contentAfterStartTag; // Yield 'generating' event with the initial potential raw chunk yield { type: "tool.generating", role: "tool.generating", callId: generatingToolCall.callId, name: generatingToolCall.name, params: generatingToolCall.params, rawChunk: potentialRawChunk, raw: potentialRawChunk, // Raw *so far* is this chunk }; // Don't update generatingToolCall.raw here, wait for end tag confirmation. // The buffer remains as is (start tag + potential raw) to check for end tag in next chunks. } } else { // No start tag found in the current buffer. Treat the buffer cautiously. // To avoid prematurely yielding partial tags, yield content only up to the last '<'. const lastLtIndex = currentBuffer.lastIndexOf("<"); if (lastLtIndex !== -1) { // Yield text before the potential partial tag start const textToYield = currentBuffer.substring(0, lastLtIndex); if (textToYield) { yield { type: "assistant", role: "assistant", content: textToYield }; accumulatedAssistantContent += textToYield; } // Keep the potential partial tag and anything after it in the buffer currentBuffer = currentBuffer.substring(lastLtIndex); } else { // No '<' found, the entire buffer is likely assistant text. yield { type: "assistant", role: "assistant", content: currentBuffer }; accumulatedAssistantContent += currentBuffer; currentBuffer = ""; // Clear buffer } } } } // End for await (chunk of llmStream) // --- Stream Ended --- // Handle incomplete tool call at end of stream if (generatingToolCall) { console.warn( `[WARN] LLM stream ended mid-tool-call for ${generatingToolCall.name}(${generatingToolCall.callId}). Treating as text.` ); // Yield the incomplete tag and raw content as assistant text const incompleteText = generatingToolCall.startTagContent + generatingToolCall.raw; if (incompleteText) { yield { type: "assistant", role: "assistant", content: incompleteText }; accumulatedAssistantContent += incompleteText; } generatingToolCall = null; // Clear the state } // Yield any remaining content in the buffer (should be assistant text) if (currentBuffer) { yield { type: "assistant", role: "assistant", content: currentBuffer }; accumulatedAssistantContent += currentBuffer; } // --- Add final assistant message to history --- // Only add if there was actual content OR if no tool calls were made (to represent an empty response) const assistantHasContent = accumulatedAssistantContent && accumulatedAssistantContent.trim().length > 0; // Check if the *last* turn involved initiating tool calls (check promises generated in *this* invocation) const hadToolCallsInThisTurn = ongoingToolCalls.length > 0; if (assistantHasContent) { // Avoid adding duplicate empty messages if the stream only yielded tool calls const lastMessage = this.messages[this.messages.length - 1]; if (!(lastMessage?.role === "assistant" && lastMessage?.content === accumulatedAssistantContent)) { this.messages.push({ role: "assistant", content: accumulatedAssistantContent, }); } } else if (!assistantHasContent && !hadToolCallsInThisTurn) { // If the LLM responded with nothing (no text, no tool calls), record an empty assistant message. const lastMessage = this.messages[this.messages.length - 1]; // Add only if the previous message wasn't already an empty assistant message. if (!(lastMessage?.role === 'assistant' && !lastMessage?.content)) { this.messages.push({ role: "assistant", content: "" }); } } } catch (error) { console.error("[ERROR] LLM stream or processing failed:", error); const errorContent = `LLM stream/processing failed: ${error.message}`; yield { type: "error", role: "error", // Use 'error' role for yielded error object content: errorContent, }; // Add error to history if not already present if (!this.messages.some(m => m.role === 'error' && m.content === errorContent)) { this.messages.push({ role: "error", content: errorContent }); } generatingToolCall = null; // Ensure state is reset on error // Do not proceed to tool result processing if the stream failed return; // Exit the generator } finally { // Ensure state is always reset, regardless of errors or normal completion generatingToolCall = null; } // --- Process Tool Results (only if stream completed successfully) --- if (ongoingToolCalls.length > 0) { let toolResults = []; try { // Wait for all initiated tool calls in this turn to complete toolResults = await Promise.all(ongoingToolCalls); } catch (error) { // This catch is for Promise.all itself rejecting, though individual errors are caught above. console.error("[ERROR] Unexpected error during Promise.all for tool calls:", error); // Yield a general error? Maybe rely on individual error reporting. } ongoingToolCalls = []; // Clear the promises for this turn let requiresRecursion = false; // Flag if any tool result needs LLM follow-up for (const resultWrapper of toolResults) { // Basic validation of the wrapper structure if (!resultWrapper || typeof resultWrapper.callId === 'undefined' || typeof resultWrapper.result === 'undefined') { console.error("[ERROR] Invalid tool result wrapper received:", resultWrapper); continue; } const { callId, result } = resultWrapper; // Find the corresponding message in history to update const toolMessageIndex = this.messages.findIndex( (m) => m.role === "tool" && m.callId === callId && m.result === null // Ensure we only update once ); if (toolMessageIndex !== -1) { requiresRecursion = true; // Found a result, LLM needs to see it const normalized = normalizeToolResult(result); // Update the history message this.messages[toolMessageIndex].result = result; // Store raw result this.messages[toolMessageIndex].content = normalized.content; // Store normalized content for display/history this.messages[toolMessageIndex].error = normalized.error; // Store potential error from normalization/tool // Add any other keys from normalized result (that aren't content/error) Object.keys(normalized).forEach(key => { if (key !== 'content' && key !== 'error') { this.messages[toolMessageIndex][key] = normalized[key]; } }); // Yield the processed tool result message yield { type: "tool", role: "tool", // Use 'tool' role for yielded result object callId: callId, name: this.messages[toolMessageIndex].name, params: this.messages[toolMessageIndex].params, raw: this.messages[toolMessageIndex].raw, result: this.messages[toolMessageIndex].result, // Yield raw result content: this.messages[toolMessageIndex].content, // Yield normalized content error: this.messages[toolMessageIndex].error, // Yield error status }; } else { // This might happen if the stream ended prematurely after the tool call was logged but before results processed. // Or if the same callId somehow got processed twice. console.warn(`[WARN] Could not find pending tool message or already processed for callId: ${callId}`); } } // If any tool results were processed, recursively call invokeAndProcess // to send the results back to the LLM and get the next response. if (requiresRecursion) { yield* this.#invokeAndProcess(); // Tail recursion simulation } } } // End #invokeAndProcess async *send(messageContent, ...args) { this.#invokeArgs = args; // Store args for potential tool use let userMessage; // Normalize incoming message to the Message structure if (typeof messageContent === "string") { userMessage = { role: "user", content: messageContent }; } else if (Array.isArray(messageContent)) { // Assume it's an array of content parts userMessage = { role: "user", content: messageContent }; } else if (typeof messageContent === "object" && messageContent !== null) { // Allow passing a pre-formatted message object if (messageContent.role === "user" && typeof messageContent.content !== 'undefined') { userMessage = messageContent; } // Allow passing a single content part object else if (messageContent.type && typeof messageContent.content === 'undefined') { userMessage = { role: "user", content: [messageContent] }; } else { console.error("Invalid object passed to send():", messageContent); throw new Error("Invalid message format. Object must have role='user' and 'content', or be a single content part object."); } } else { throw new Error("Invalid message format. Must be string, array of content parts, or user message object."); } this.messages.push(userMessage); try { // Start the generation and processing loop yield* this.#invokeAndProcess(); } catch (error) { // Catch critical errors from the invoke/processing loop itself console.error("[ERROR] Critical conversation processing error in send():", error); const errorContent = `Conversation failed critically: ${error.message}`; const errorMsg = { type: "error", role: "error", content: errorContent, }; yield errorMsg; // Yield the error to the consumer // Add to history if not already there if (!this.messages.some(m => m.role === 'error' && m.content === errorContent)) { this.messages.push({ role: "error", content: errorContent }); } } finally { // Clean up invoke args after the send operation (including all recursive calls) completes or errors out this.#invokeArgs = []; } } }