paper-burner/js/processing/react_engine.js

270 lines
9.0 KiB
JavaScript

/**
* ReAct Engine
* Implements Reason + Act loop for complex task solving
*
* This engine follows the ReAct pattern:
* 1. Thought: Analyze the current situation
* 2. Action: Decide to use a tool
* 3. Observation: See the result of the tool
* 4. Repeat until Final Answer
*/
class ReActEngine {
/**
* @param {Object} config
* @param {number} config.maxSteps - Maximum number of steps before timeout
* @param {Function} config.llmCaller - Function(prompt) => Promise<string>
*/
constructor(config = {}) {
this.maxSteps = config.maxSteps || 10;
this.llmCaller = config.llmCaller;
this.tools = new Map();
this.history = [];
this.listeners = new Map();
this.abortController = null;
}
/**
* Register a tool for the engine to use
* @param {string} name - Tool name (e.g., "Search")
* @param {string} description - Tool description
* @param {Function} executeFunc - Async function to execute the tool
*/
registerTool(name, description, executeFunc) {
this.tools.set(name, {
name,
description,
execute: executeFunc
});
}
/**
* Subscribe to engine events
* @param {string} event - Event name (start, thinking, thought, action_start, action_end, success, error, timeout)
* @param {Function} callback
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
emit(event, data) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(cb => cb(data));
}
}
/**
* Stop the current execution
*/
abort() {
if (this.abortController) {
this.abortController.abort();
this.emit('abort', {});
}
}
/**
* Run the ReAct loop
* @param {string} goal - The user's question or task
*/
async run(goal) {
this.history = [];
this.abortController = new AbortController();
this.emit('start', { goal });
let stepCount = 0;
try {
while (stepCount < this.maxSteps) {
if (this.abortController.signal.aborted) {
throw new Error('Aborted by user');
}
stepCount++;
// 1. Construct Prompt
const prompt = this.constructPrompt(goal);
// 2. Get Thought and Action from LLM
this.emit('thinking', { step: stepCount, prompt });
let llmResponse;
try {
llmResponse = await this.llmCaller(prompt, this.abortController.signal);
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new Error(`LLM Call failed: ${err.message}`);
}
// Clean up response (sometimes LLMs repeat the prompt or add extra newlines)
llmResponse = llmResponse.trim();
this.emit('thought', { step: stepCount, content: llmResponse });
// 3. Parse Response
const parsed = this.parseResponse(llmResponse);
if (parsed.finalAnswer) {
this.emit('success', { answer: parsed.finalAnswer, history: this.history });
return parsed.finalAnswer;
}
if (!parsed.action) {
// If no action found, treat as a thought or clarification request
// We append it to history so the LLM knows what it just said
this.history.push({ type: 'thought', content: parsed.thought || llmResponse });
// If the LLM just outputs thoughts without actions for too long, it might be stuck.
// But for now we continue.
continue;
}
// 4. Execute Action
const toolName = parsed.action.tool;
const toolArgs = parsed.action.args;
this.emit('action_start', { step: stepCount, tool: toolName, args: toolArgs });
let observation;
try {
const tool = this.tools.get(toolName);
if (!tool) {
observation = `Error: Tool '${toolName}' not found. Available tools: ${Array.from(this.tools.keys()).join(', ')}`;
} else {
observation = await tool.execute(toolArgs);
}
} catch (error) {
observation = `Error executing tool '${toolName}': ${error.message}`;
}
// Truncate observation if too long to avoid context window issues
if (typeof observation === 'string' && observation.length > 2000) {
observation = observation.substring(0, 2000) + '... (truncated)';
}
this.emit('action_end', { step: stepCount, tool: toolName, output: observation });
// 5. Update History
this.history.push({
type: 'step',
thought: parsed.thought,
action: parsed.action,
observation: observation
});
}
this.emit('timeout', { maxSteps: this.maxSteps });
throw new Error('Max steps reached without final answer');
} catch (error) {
if (error.message === 'Aborted by user') {
// Handled gracefully
} else {
this.emit('error', { error: error.message });
throw error;
}
} finally {
this.abortController = null;
}
}
constructPrompt(goal) {
const toolDescriptions = Array.from(this.tools.values())
.map(t => `${t.name}: ${t.description}`)
.join('\n');
let prompt = `Answer the following questions as best you can. You have access to the following tools:
${toolDescriptions}
Use the following format:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [${Array.from(this.tools.keys()).join(', ')}]
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question
Begin!
Question: ${goal}
`;
for (const step of this.history) {
if (step.type === 'step') {
prompt += `Thought: ${step.thought}\n`;
prompt += `Action: ${step.action.tool}\n`;
prompt += `Action Input: ${step.action.args}\n`;
prompt += `Observation: ${step.observation}\n`;
} else if (step.type === 'thought') {
prompt += `Thought: ${step.content}\n`;
}
}
// Prompt for the next step
prompt += `Thought:`;
return prompt;
}
parseResponse(response) {
// Handle case where LLM includes "Thought:" prefix in the response
let content = response;
if (content.startsWith('Thought:')) {
content = content.substring(8).trim();
}
// Look for Final Answer
const finalAnswerRegex = /Final Answer:([\s\S]+)/i;
const finalMatch = content.match(finalAnswerRegex);
if (finalMatch) {
return { finalAnswer: finalMatch[1].trim() };
}
// Look for Action and Action Input
// We look for the LAST occurrence of Action/Action Input if multiple are hallucinated,
// but typically we stop generation at Observation so there should be only one.
const actionRegex = /Action:\s*(.+?)(?:\n|$)/i;
const inputRegex = /Action Input:\s*([\s\S]+?)(?:\n|$)/i;
const actionMatch = content.match(actionRegex);
const inputMatch = content.match(inputRegex);
if (actionMatch) {
const tool = actionMatch[1].trim();
let args = '';
if (inputMatch) {
args = inputMatch[1].trim();
}
// Extract thought (everything before Action)
const thoughtEndIndex = content.indexOf('Action:');
const thought = content.substring(0, thoughtEndIndex).trim();
return {
thought,
action: {
tool,
args
}
};
}
// If no action, everything is a thought
return { thought: content };
}
}
// Export
if (typeof window !== 'undefined') {
window.ReActEngine = ReActEngine;
}
if (typeof module !== 'undefined') {
module.exports = ReActEngine;
}