184 lines
9.5 KiB
JavaScript
184 lines
9.5 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.Loop = void 0;
|
|
const registry_1 = require("./providers/registry");
|
|
const cache_1 = require("./cache");
|
|
const summary_1 = require("./summary");
|
|
class Loop {
|
|
_provider;
|
|
_loopOptions;
|
|
_cacheOutput = {};
|
|
constructor(options) {
|
|
this._provider = (0, registry_1.getProvider)(options.api);
|
|
this._loopOptions = options;
|
|
}
|
|
async run(task, runOptions = {}) {
|
|
const options = { ...this._loopOptions, ...runOptions };
|
|
const allTools = [...(options.tools || []).map(wrapToolWithIsDone)];
|
|
const conversation = {
|
|
systemPrompt,
|
|
messages: [
|
|
{ role: 'user', content: task },
|
|
],
|
|
tools: allTools,
|
|
};
|
|
const debug = options.debug;
|
|
const budget = {
|
|
tokens: options.maxTokens,
|
|
toolCalls: options.maxToolCalls,
|
|
toolCallRetries: options.maxToolCallRetries,
|
|
};
|
|
const totalUsage = { input: 0, output: 0 };
|
|
debug?.('lowire:loop')(`Starting ${this._provider.name} loop\n${task}`);
|
|
const maxTurns = options.maxTurns || 100;
|
|
for (let turns = 0; turns < maxTurns; ++turns) {
|
|
if (options.maxTokens && budget.tokens !== undefined && budget.tokens <= 0)
|
|
return { status: 'error', error: `Budget tokens ${options.maxTokens} exhausted`, usage: totalUsage, turns };
|
|
debug?.('lowire:loop')(`Turn ${turns + 1} of (max ${maxTurns})`);
|
|
const caches = options.cache ? {
|
|
input: options.cache,
|
|
output: this._cacheOutput,
|
|
} : undefined;
|
|
const summarizedConversation = options.summarize ? this._summarizeConversation(task, conversation, options) : conversation;
|
|
await options.onBeforeTurn?.({ conversation: summarizedConversation, totalUsage, budgetTokens: budget.tokens });
|
|
if (options.signal?.aborted)
|
|
return { status: 'break', usage: totalUsage, turns };
|
|
debug?.('lowire:loop')(`Request`, JSON.stringify({ ...summarizedConversation, tools: `${summarizedConversation.tools.length} tools` }, null, 2));
|
|
const tokenEstimate = Math.floor(JSON.stringify(summarizedConversation).length / 4);
|
|
if (budget.tokens !== undefined && tokenEstimate >= budget.tokens)
|
|
return { status: 'error', error: `Input token estimate ${tokenEstimate} exceeds budget ${budget.tokens}`, usage: totalUsage, turns };
|
|
const { result: assistantMessage, usage } = await (0, cache_1.cachedComplete)(this._provider, summarizedConversation, caches, {
|
|
...options,
|
|
maxTokens: budget.tokens !== undefined ? budget.tokens - tokenEstimate : undefined,
|
|
signal: options.signal,
|
|
});
|
|
if (assistantMessage.stopReason.code === 'error')
|
|
return { status: 'error', error: assistantMessage.stopReason.message, usage: totalUsage, turns };
|
|
if (assistantMessage.stopReason.code === 'max_tokens')
|
|
return { status: 'error', error: `Max tokens exhausted`, usage: totalUsage, turns };
|
|
const intent = assistantMessage.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
totalUsage.input += usage.input;
|
|
totalUsage.output += usage.output;
|
|
if (budget.tokens !== undefined)
|
|
budget.tokens -= usage.input + usage.output;
|
|
debug?.('lowire:loop')('Usage', `input: ${usage.input}, output: ${usage.output}`);
|
|
debug?.('lowire:loop')('Assistant', intent, JSON.stringify(assistantMessage.content, null, 2));
|
|
await options.onAfterTurn?.({ assistantMessage, totalUsage, budgetTokens: budget.tokens });
|
|
if (options.signal?.aborted)
|
|
return { status: 'break', usage: totalUsage, turns };
|
|
conversation.messages.push(assistantMessage);
|
|
const toolCalls = assistantMessage.content.filter(part => part.type === 'tool_call');
|
|
if (toolCalls.length === 0) {
|
|
assistantMessage.toolError = 'Error: tool call is expected in every assistant message. Call the "report_result" tool when the task is complete.';
|
|
continue;
|
|
}
|
|
for (const toolCall of toolCalls) {
|
|
if (budget.toolCalls !== undefined && --budget.toolCalls < 0)
|
|
return { status: 'error', error: `Failed to perform step, max tool calls (${options.maxToolCalls}) reached`, usage: totalUsage, turns };
|
|
const { name, arguments: args } = toolCall;
|
|
debug?.('lowire:loop')('Call tool', name, JSON.stringify(args, null, 2));
|
|
const status = await options.onBeforeToolCall?.({ assistantMessage, toolCall });
|
|
if (options.signal?.aborted)
|
|
return { status: 'break', usage: totalUsage, turns };
|
|
if (status === 'disallow') {
|
|
toolCall.result = {
|
|
content: [{ type: 'text', text: 'Tool call is disallowed.' }],
|
|
isError: true,
|
|
};
|
|
continue;
|
|
}
|
|
try {
|
|
const result = await options.callTool({
|
|
name,
|
|
arguments: {
|
|
...args,
|
|
_meta: {
|
|
'dev.lowire/intent': intent,
|
|
'dev.lowire/history': true,
|
|
'dev.lowire/state': true,
|
|
}
|
|
}
|
|
});
|
|
const text = result.content.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
debug?.('lowire:loop')('Tool result', text, JSON.stringify(result, null, 2));
|
|
const status = await options.onAfterToolCall?.({ assistantMessage, toolCall, result });
|
|
if (options.signal?.aborted)
|
|
return { status: 'break', usage: totalUsage, turns };
|
|
if (status === 'disallow') {
|
|
toolCall.result = {
|
|
content: [{ type: 'text', text: 'Tool result is disallowed to be reported.' }],
|
|
isError: true,
|
|
};
|
|
continue;
|
|
}
|
|
toolCall.result = result;
|
|
if (args._is_done && !result.isError)
|
|
return { result, status: 'ok', usage: totalUsage, turns };
|
|
}
|
|
catch (error) {
|
|
const errorMessage = `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`;
|
|
await options.onToolCallError?.({ assistantMessage, toolCall, error });
|
|
if (options.signal?.aborted)
|
|
return { status: 'break', usage: totalUsage, turns };
|
|
toolCall.result = {
|
|
content: [{ type: 'text', text: errorMessage }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
const hasErrors = toolCalls.some(toolCall => toolCall.result?.isError);
|
|
if (!hasErrors)
|
|
budget.toolCallRetries = options.maxToolCallRetries;
|
|
if (hasErrors && budget.toolCallRetries !== undefined && --budget.toolCallRetries < 0)
|
|
return { status: 'error', error: `Failed to perform action after ${options.maxToolCallRetries} tool call retries`, usage: totalUsage, turns };
|
|
}
|
|
return { status: 'error', error: `Failed to perform step, max attempts reached`, usage: totalUsage, turns: maxTurns };
|
|
}
|
|
_summarizeConversation(task, conversation, options) {
|
|
const { summary, lastMessage } = (0, summary_1.summarizeConversation)(task, conversation, options);
|
|
return {
|
|
...conversation,
|
|
messages: [
|
|
{ role: 'user', content: summary },
|
|
...lastMessage ? [lastMessage] : [],
|
|
],
|
|
};
|
|
}
|
|
cache() {
|
|
return this._cacheOutput;
|
|
}
|
|
}
|
|
exports.Loop = Loop;
|
|
function wrapToolWithIsDone(tool) {
|
|
const inputSchema = { ...tool.inputSchema };
|
|
inputSchema.properties = {
|
|
...inputSchema.properties,
|
|
_is_done: { type: 'boolean', description: 'Whether the task is complete. If false, agentic loop will continue to perform the task.' },
|
|
};
|
|
inputSchema.required = [...(inputSchema.required || []), '_is_done'];
|
|
return {
|
|
...tool,
|
|
inputSchema,
|
|
};
|
|
}
|
|
const systemPrompt = `
|
|
- You are an autonomous agent designed to complete tasks by interacting with tools.
|
|
- Perform the user task.
|
|
- If you see text surrounded by %, it is a secret and you should preserve it as such. It will be replaced with the actual value before the tool call.
|
|
`;
|