tinyagent-ts
Version:
Modern TypeScript framework for building AI agents with pluggable tools and ReAct reasoning
325 lines • 13.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Agent = void 0;
// src/agent.ts
require("reflect-metadata");
const decorators_1 = require("./decorators");
const promptEngine_1 = require("./promptEngine");
const final_answer_tool_1 = require("./final-answer.tool");
const schemas_1 = require("./schemas");
const json_1 = require("./utils/json");
const truncate_1 = require("./utils/truncate");
const zod_1 = require("zod");
/**
* Abstract base class for creating AI agents.
* Agents can be equipped with tools (defined by `@tool` decorator) and use an LLM
* (specified by `@model` decorator) to process input and decide whether to use a tool
* or respond directly.
*
* @template I - The type of the input the agent's `run` method accepts. Defaults to `string`.
* @template O - The type of the output the agent's `run` method produces. Defaults to `string`.
*/
class Agent {
/**
* Initializes a new instance of the Agent.
* It requires the `OPENROUTER_API_KEY` environment variable to be set.
* @throws Error if `OPENROUTER_API_KEY` is not found in the environment variables.
*/
constructor(options = {}) {
/** Conversation memory for ReAct loop */
this.memory = [];
/** Simple logger with debug() method */
this.logger = console;
const { systemPrompt, systemPromptFile } = options;
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
// TODO: Replace with AgentInitializationError
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
this.apiKey = apiKey;
this.customSystemPrompt = systemPrompt;
this.promptEngine = new promptEngine_1.PromptEngine({}, systemPromptFile ? { agent: systemPromptFile } : {});
}
/**
* Retrieves the LLM model name associated with this agent class.
* The model name is specified using the `@model` decorator.
* @returns The model name string.
* @throws Error if the `@model` decorator is missing on the agent class.
* @internal
*/
getModelName() {
const id = Reflect.getMetadata(decorators_1.META_KEYS.MODEL, this.constructor);
if (!id) {
// TODO: Replace with AgentConfigurationError
throw new Error('Missing @model decorator on the Agent class.');
}
return id;
}
/**
* Builds a registry of tools available to this agent.
* Tools are defined using the `@tool` decorator on methods of the agent class.
* @returns A record mapping tool names to their `ToolHandle` (metadata and call function).
* @internal
*/
buildToolRegistry() {
const metaList = Reflect.getMetadata(decorators_1.META_KEYS.TOOLS, this.constructor) || [];
const registry = Object.fromEntries(metaList.map((m) => [
m.name,
{
meta: m,
call: async (args) => {
try {
const parsed = m.schema.parse(args);
return await this[m.method](parsed);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Error executing tool "${m.name}": ${message}`);
}
},
},
]));
const finalTool = new final_answer_tool_1.FinalAnswerTool();
registry[finalTool.name] = {
meta: {
name: finalTool.name,
description: finalTool.description,
method: 'forward',
schema: finalTool.schema,
},
call: async (args) => {
const parsed = finalTool.schema.parse(args);
return finalTool.forward(parsed);
},
};
return registry;
}
/**
* Makes a request to the OpenRouter API.
* @param messages - An array of message objects to send to the LLM.
* @param model - The name of the LLM model to use.
* @returns A promise that resolves with the API response.
* @throws Error if the API request fails or returns an error status.
* @internal
*/
async makeOpenRouterRequest(messages, model) {
try {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
// TODO: Make these configurable or remove/improve defaults
'HTTP-Referer': 'https://github.com/yourusername/tinyagent-ts',
'X-Title': 'TinyAgent-TS',
},
body: JSON.stringify({ model, messages }),
});
if (!res.ok) {
let errorDetails = { message: 'Failed to parse error response' };
try {
errorDetails = await res.json();
}
catch (parseError) {
// Ignore parsing error, use default message
}
// TODO: Replace with LLMCommunicationError
throw new Error(`OpenRouter API error: ${res.status} ${res.statusText}. Details: ${JSON.stringify(errorDetails)}`);
}
// Explicitly type data after parsing JSON
const data = await res.json();
// Perform type checks before accessing properties
if (!data ||
typeof data !== 'object' ||
!data.choices ||
!Array.isArray(data.choices) ||
data.choices.length === 0 ||
typeof data.choices[0]?.message?.content !== 'string' // Check nested structure
) {
console.error('Invalid OpenRouter response structure:', JSON.stringify(data));
throw new Error('Invalid response structure received from OpenRouter API');
}
// Now it's safer to assert the type
return data;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
// TODO: Replace with LLMCommunicationError
throw new Error(`Failed to call OpenRouter API: ${message}`);
}
}
/**
* Main entry point for running the agent.
* It processes the input, interacts with the LLM, and potentially uses tools
* to generate a final output.
* @param input - The input to be processed by the agent.
* @returns A promise that resolves with the agent's final output.
*/
async run(input) {
const modelName = this.getModelName();
const tools = this.buildToolRegistry();
const toolCatalog = Object.values(tools)
.filter((t) => t.meta.name !== 'final_answer')
.map((t) => `- ${t.meta.name}: ${t.meta.description}`)
.join('\n');
// Use new helper to build initial messages
if (this.memory.length === 0) {
const initialMessages = this.buildInitialMessages(input, toolCatalog);
for (const msg of initialMessages) {
this.memory.push(msg);
}
}
else {
this.memory.push({ role: 'user', content: String(input) });
}
const finalTool = new final_answer_tool_1.FinalAnswerTool();
const maxSteps = 5;
let usedTool = false;
const MAX_BAD_CALLS = 2;
let badToolCalls = 0;
for (let step = 0; step < maxSteps; step++) {
const responseBody = await this.makeOpenRouterRequest(this.memory, modelName);
const rawReply = responseBody.choices[0]?.message?.content?.trim() ?? '';
let parsed;
let validation;
const jsonText = (0, json_1.extractJson)(rawReply);
if (jsonText) {
try {
parsed = JSON.parse(jsonText);
validation = schemas_1.AssistantReplySchema.safeParse(parsed);
}
catch (err) {
validation = { success: false, error: new Error('Invalid JSON') };
}
}
else {
validation = { success: false, error: new Error('Invalid JSON') };
}
if (!validation.success) {
const { fixed, fixedParsed } = await this.retryWithFixRequest(rawReply, validation.error);
if (!fixedParsed) {
this.memory.push({
role: 'assistant',
content: `ERROR: Unable to produce valid schema output.`,
});
return { answer: String(fixed) };
}
parsed = fixedParsed;
this.memory.push({
role: 'assistant',
content: JSON.stringify(parsed),
});
}
else {
this.memory.push({ role: 'assistant', content: rawReply });
}
const toolName = parsed.tool;
const toolArgs = parsed.args;
if (toolName === finalTool.name) {
if (!usedTool) {
console.warn('final_answer called before any other tool');
}
const validated = finalTool.schema.parse(toolArgs ?? {});
const answer = await finalTool.forward(validated);
return answer;
}
const selectedTool = tools[toolName];
if (!selectedTool) {
badToolCalls++;
const msg = `Tool ${toolName} not found.`;
if (badToolCalls >= MAX_BAD_CALLS) {
return { answer: `${msg} (too many bad tool calls)` };
}
this.logger.debug(`step ${step} → ${toolName} ERROR`, msg);
this.memory.push({
role: 'assistant',
content: JSON.stringify({ observation: msg }),
});
continue;
}
let toolResult;
try {
toolResult = await selectedTool.call(toolArgs);
usedTool = true;
const out = (0, truncate_1.truncateJson)(toolResult);
this.logger.debug(`step ${step} → ${toolName}`, out);
this.memory.push({
role: 'assistant',
content: JSON.stringify({ observation: out }),
});
}
catch (error) {
badToolCalls++;
let msg = '';
if (error instanceof zod_1.z.ZodError) {
const issue = error.issues[0];
msg =
`Tool ${toolName} failed: '${issue?.path.join('.')}' ${issue?.message}`.trim();
}
else {
msg = `Tool ${toolName} failed: ${error instanceof Error ? error.message : String(error)}`;
}
if (badToolCalls >= MAX_BAD_CALLS) {
return { answer: `${msg} (too many bad tool calls)` };
}
this.logger.debug(`step ${step} → ${toolName} ERROR`, msg);
this.memory.push({
role: 'assistant',
content: JSON.stringify({ observation: msg }),
});
}
continue;
}
throw new Error(`Loop exceeded ${maxSteps} steps without final_answer call.`);
}
/**
* Helper to build the initial LLM messages (system + user).
*/
buildInitialMessages(input, toolCatalog) {
const defaultPrompt = this.promptEngine.render('agent', {
tools: toolCatalog,
});
const systemPrompt = this.customSystemPrompt ?? defaultPrompt;
return [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: String(input) },
];
}
/**
* Helper to retry LLM output with a fix request if schema validation fails.
* Prompts the LLM to correct its output to match the AssistantReplySchema.
*/
async retryWithFixRequest(rawReply, error) {
// Compose a retry prompt
const schemaString = schemas_1.AssistantReplySchema.toString();
const errorMsg = error instanceof zod_1.z.ZodError ? error.toString() : String(error);
const retryPrompt = [
{
role: 'system',
content: 'Your previous response did not match the required schema. Please fix your output to match the following schema exactly:\n\n' +
schemaString +
'\n\nValidation error:\n' +
errorMsg,
},
{ role: 'user', content: rawReply },
];
// Call LLM with retry prompt
const modelName = this.getModelName();
const responseBody = await this.makeOpenRouterRequest(retryPrompt, modelName);
const fixed = responseBody.choices[0]?.message?.content?.trim() ?? '';
let fixedParsed = null;
try {
fixedParsed = JSON.parse(fixed);
const validation = schemas_1.AssistantReplySchema.safeParse(fixedParsed);
if (!validation.success) {
fixedParsed = null;
}
}
catch {
fixedParsed = null;
}
return { fixed, fixedParsed };
}
}
exports.Agent = Agent;
//# sourceMappingURL=agent.js.map