140 lines
6.5 KiB
JavaScript
140 lines
6.5 KiB
JavaScript
const Conversation = require("./conversation");
|
|
const lmStudio = require("./lmstudio");
|
|
|
|
const MAX_TRANSPORT_ATTEMPTS = 10;
|
|
const MAX_CONTINUATIONS = 10;
|
|
const CONTINUE_PROMPT = [
|
|
"Your previous response ended before you produced the requested final answer.",
|
|
"Continue from where you left off. Do not restart the task or discard the work you already did.",
|
|
"Keep investigating as deeply as necessary. When you have actually finished, provide the final deliverable required by the original prompt."
|
|
].join("\n");
|
|
|
|
module.exports = utils => {
|
|
const settings = utils.getSettings("llm");
|
|
const client = String(settings.client || "lm-studio").toLowerCase();
|
|
if (client !== "lm-studio" && client !== "lmstudio") throw new Error(`Unsupported LLM client: ${settings.client}`);
|
|
const llm = lmStudio;
|
|
let warmupPromise = null;
|
|
|
|
function warmUp() {
|
|
if (!warmupPromise) warmupPromise = llm.warmUp(settings);
|
|
return warmupPromise;
|
|
}
|
|
|
|
function prepare(name, systemPrompt, segments = []) {
|
|
const conversation = Conversation.start(systemPrompt);
|
|
for (const segment of segments) conversation.add(segment);
|
|
return { name: name || "anonymous", conversation };
|
|
}
|
|
|
|
function liveOutput() {
|
|
let channel = null;
|
|
let wrote = false;
|
|
function switchTo(next) {
|
|
if (channel === next) return;
|
|
if (wrote) process.stdout.write("\n");
|
|
process.stdout.write(` --- ${next} ---\n`);
|
|
channel = next;
|
|
wrote = true;
|
|
}
|
|
return {
|
|
reasoning(text) { switchTo("reasoning"); process.stdout.write(text); },
|
|
content(text) { switchTo("response"); process.stdout.write(text); },
|
|
end() { if (wrote) process.stdout.write("\n"); }
|
|
};
|
|
}
|
|
|
|
function addFallbackContinuation(conversation, reply) {
|
|
if (reply.reasoning?.trim()) {
|
|
conversation.add({
|
|
role: "assistant",
|
|
content: [
|
|
"[UNFINISHED PRIVATE WORK FROM MY PREVIOUS RESPONSE]",
|
|
reply.reasoning.trim(),
|
|
"[END UNFINISHED PRIVATE WORK]"
|
|
].join("\n")
|
|
});
|
|
}
|
|
conversation.add({ role: "user", content: CONTINUE_PROMPT });
|
|
}
|
|
|
|
async function askConversation(agent, conversation, options = {}) {
|
|
await warmUp();
|
|
const callerOwnsOutput = typeof options.onReasoning === "function" || typeof options.onContent === "function";
|
|
const reasoning = [];
|
|
let previousResponseId = null;
|
|
let useStatefulContinuation = false;
|
|
|
|
for (let continuation = 0; continuation <= MAX_CONTINUATIONS; continuation++) {
|
|
for (let attempt = 1; attempt <= MAX_TRANSPORT_ATTEMPTS; attempt++) {
|
|
const output = !callerOwnsOutput && options.live !== false ? liveOutput() : null;
|
|
const sendOptions = {
|
|
...options,
|
|
onReasoning: options.onReasoning || output?.reasoning,
|
|
onContent: options.onContent || output?.content
|
|
};
|
|
delete sendOptions.live;
|
|
if (useStatefulContinuation && previousResponseId) {
|
|
sendOptions.previousResponseId = previousResponseId;
|
|
sendOptions.continuationInput = CONTINUE_PROMPT;
|
|
}
|
|
|
|
try {
|
|
const reply = await llm.send(conversation, settings, sendOptions);
|
|
output?.end();
|
|
if (reply.reasoning?.trim()) reasoning.push(reply.reasoning.trim());
|
|
if (reply.content?.trim()) {
|
|
delete reply.responseId;
|
|
if (reasoning.length > 0) reply.reasoning = reasoning.join("\n\n--- continuation ---\n\n");
|
|
return reply;
|
|
}
|
|
if (continuation >= MAX_CONTINUATIONS) {
|
|
throw Object.assign(new Error(`LLM returned no final response content for ${agent.name} after ${MAX_CONTINUATIONS} continuations.`), { retryable: false });
|
|
}
|
|
console.error();
|
|
console.error(`LLM returned unfinished reasoning with no final response for ${agent.name}; continuing (${continuation + 1}/${MAX_CONTINUATIONS}).`);
|
|
if (reply.responseId) {
|
|
previousResponseId = reply.responseId;
|
|
useStatefulContinuation = true;
|
|
} else {
|
|
previousResponseId = null;
|
|
useStatefulContinuation = false;
|
|
addFallbackContinuation(conversation, reply);
|
|
}
|
|
break;
|
|
} catch (error) {
|
|
output?.end();
|
|
const retryable = error.retryable !== false;
|
|
console.error();
|
|
console.error(`LLM request failed for ${agent.name} (attempt ${attempt}/${MAX_TRANSPORT_ATTEMPTS}): ${error.message}`);
|
|
if (error.cause) console.error(` Cause: ${error.cause.code || error.cause.message || String(error.cause)}`);
|
|
if (error.partial) console.error(" Stream ended after partial output; retry will restart this response.");
|
|
if (!retryable || attempt === MAX_TRANSPORT_ATTEMPTS) throw error;
|
|
const delayMs = Math.min(attempt, 5) * 1000;
|
|
console.error(` Retrying in ${delayMs / 1000} second${delayMs === 1000 ? "" : "s"}...`);
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`LLM request for ${agent.name} failed unexpectedly.`);
|
|
}
|
|
|
|
async function ask(agent, input, options = {}) {
|
|
const conversation = Conversation.from(agent.conversation);
|
|
conversation.add({ role: "user", content: input });
|
|
return askConversation(agent, conversation, options);
|
|
}
|
|
|
|
async function followUp(agent, originalInput, previousReply, input, options = {}) {
|
|
const conversation = Conversation.from(agent.conversation);
|
|
conversation.add({ role: "user", content: originalInput });
|
|
conversation.add(previousReply);
|
|
conversation.add({ role: "user", content: input });
|
|
return askConversation(agent, conversation, options);
|
|
}
|
|
|
|
function model() { return settings.model; }
|
|
|
|
return { warmUp, prepare, ask, followUp, model };
|
|
};
|