@agentforce/adk
Version:
AgentForce Agent Development Kit - A powerful framework for building AI agents and servers
633 lines (544 loc) • 27 kB
text/typescript
import OpenAI from "openai";
import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions";
import type { Tool } from "../types";
import { executeTool } from "../agent/functions/tools";
import type { AgentForceLogger, ModelConfig } from "../types";
import { writeFileSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { truncate } from "../utils/truncate";
/**
* Interface for OpenRouter tool use functionality
* @interface OpenRouterToolUseInterface
* @property {function} generateWithTools - Generate response with tool support using a prompt
* @property {function} chatWithTools - Chat with tool support using message history
*/
export interface OpenRouterToolUseInterface {
generateWithTools(prompt: string, tools: Tool[], system?: string, logger?: AgentForceLogger, agent?: any): Promise<string>;
chatWithTools(messages: Array<{ role: string; content: string }>, tools: Tool[], logger?: AgentForceLogger, agent?: any): Promise<string>;
}
// Re-export types for convenience
export type { Tool, AgentForceLogger, ModelConfig };
/**
* OpenRouter tool use functionality for the AgentForce SDK
* Handles tool execution with OpenRouter models via OpenAI-compatible interface
*/
export class OpenRouterToolUse implements OpenRouterToolUseInterface {
private model: string;
private modelConfig?: ModelConfig;
private client: OpenAI;
constructor(model: string, modelConfig?: ModelConfig) {
this.model = model;
this.modelConfig = modelConfig;
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable is required");
}
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: apiKey,
defaultHeaders: {
"HTTP-Referer": process.env.YOUR_SITE_URL || "https://agentforce.zone",
"X-Title": process.env.YOUR_SITE_NAME || "AgentForce ADK",
},
});
}
/**
* Convert AgentForce Tool format to OpenAI ChatCompletionTool format
* @param tools - Array of AgentForce tool definitions
* @returns Array of OpenAI-compatible tool definitions
*/
private convertToolsToOpenAIFormat(tools: Tool[]): ChatCompletionTool[] {
return tools.map(tool => ({
type: "function",
function: {
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
},
}));
}
/**
* Get the options for OpenRouter API calls
* Merges default options with user-provided ModelConfig
*/
private getOpenRouterOptions(): { temperature?: number; max_tokens?: number } {
const options: { temperature?: number; max_tokens?: number } = {};
if (!this.modelConfig) {
return options;
}
if (this.modelConfig.temperature !== undefined) {
options.temperature = this.modelConfig.temperature;
}
if (this.modelConfig.maxTokens !== undefined) {
options.max_tokens = this.modelConfig.maxTokens;
}
return options;
}
/**
* Apply request delay if configured
* Helps prevent rate limiting by spacing out API calls
*/
private async applyRequestDelay(logger?: AgentForceLogger): Promise<void> {
if (this.modelConfig?.requestDelay && this.modelConfig.requestDelay > 0) {
const delayMs = this.modelConfig.requestDelay * 1000; // Convert seconds to milliseconds
if (logger) {
logger.debug("Applying request delay", {
delaySeconds: this.modelConfig.requestDelay,
delayMs,
provider: "openrouter",
model: this.model,
});
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
/**
* Sanitize tool results for LLM context by removing large binary data
* This prevents context overflow while preserving useful metadata
* Auto-saves screenshots and provides file paths
*/
private sanitizeToolResultForContext(result: any): any {
if (typeof result !== "object" || result === null) {
return result;
}
const sanitized = { ...result };
// Helper function to detect base64 image data
const isBase64Image = (str: string): boolean => {
if (typeof str !== "string" || str.length < 100) return false;
// Check for base64 image patterns
return /^[A-Za-z0-9+/]{100,}={0,2}$/.test(str) ||
/^data:image\/[^;]+;base64,/.test(str);
};
// Helper function to save binary data to file
const saveBinaryToFile = (data: string, prefix: string = "binary"): string => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const urlPart = sanitized.url ?
sanitized.url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 20) :
"unknown";
// Determine file extension from data
let extension = "bin";
if (data.startsWith("data:image/png") || isBase64Image(data)) {
extension = "png";
} else if (data.startsWith("data:image/jpeg") || data.startsWith("data:image/jpg")) {
extension = "jpg";
} else if (data.startsWith("data:image/gif")) {
extension = "gif";
}
const filename = `${prefix}_${urlPart}_${timestamp}.${extension}`;
const absolutePath = resolve(process.cwd(), filename);
const dirPath = dirname(absolutePath);
mkdirSync(dirPath, { recursive: true });
// Clean base64 data (remove data URL prefix if present)
let cleanData = data;
if (data.startsWith("data:")) {
cleanData = data.split(",")[1] || data;
}
const originalLength = cleanData.length;
writeFileSync(absolutePath, cleanData, "base64");
return `[BINARY_SAVED_TO: ${filename}, SIZE: ${Math.round(originalLength * 0.75)} bytes]`;
} catch (error: any) {
return `[BINARY_SAVE_FAILED: ${error.message}]`;
}
};
// Auto-save screenshot data and replace with file path info
if (sanitized.screenshot && typeof sanitized.screenshot === "string") {
if (isBase64Image(sanitized.screenshot)) {
sanitized.screenshot = saveBinaryToFile(sanitized.screenshot, "screenshot");
sanitized.screenshotSaved = true;
}
}
// Handle other common binary data fields
const binaryFields = ["image", "photo", "picture", "screenshotData", "imageData"];
for (const field of binaryFields) {
if (sanitized[field] && typeof sanitized[field] === "string" && isBase64Image(sanitized[field])) {
sanitized[field] = saveBinaryToFile(sanitized[field], field);
sanitized[`${field}Saved`] = true;
}
}
// Recursively check nested objects for binary data
for (const [key, value] of Object.entries(sanitized)) {
if (typeof value === "object" && value !== null) {
sanitized[key] = this.sanitizeToolResultForContext(value);
}
}
// Truncate other potentially large text data
if (sanitized.html && typeof sanitized.html === "string" && sanitized.html.length > 10000) {
sanitized.html = sanitized.html.substring(0, 2000) + "...[truncated]";
}
if (sanitized.content && typeof sanitized.content === "string" && sanitized.content.length > 10000) {
sanitized.content = sanitized.content.substring(0, 3000) + "...[truncated]";
}
// Handle very large strings that might be binary data
for (const [key, value] of Object.entries(sanitized)) {
if (typeof value === "string" && value.length > 5000) {
if (isBase64Image(value)) {
sanitized[key] = saveBinaryToFile(value, key);
sanitized[`${key}Saved`] = true;
} else if (value.length > 15000) {
// Truncate very large non-binary strings
sanitized[key] = value.substring(0, 3000) + "...[truncated]";
}
}
}
return sanitized;
}
/**
* Generate response with tool support using the OpenRouter model
* @param prompt - The user prompt to send to the model
* @param tools - Array of tool definitions
* @param system - Optional system prompt
* @param logger - Optional logger for debugging
* @param agent - Optional agent instance for MCP tool execution
* @returns Promise with the model's response after tool execution
*/
async generateWithTools(prompt: string, tools: Tool[], system?: string, logger?: AgentForceLogger, agent?: any): Promise<string> {
try {
if (logger) {
logger.debug("Initial OpenRouter LLM call with tools", {
model: this.model,
toolsAvailable: tools.map(t => t.function.name),
prompt: prompt.substring(0, 100) + "...",
});
}
// Prepare conversation messages
const messages: ChatCompletionMessageParam[] = [
...(system ? [{ role: "system" as const, content: system }] : []),
{ role: "user" as const, content: prompt },
];
const maxRounds = this.modelConfig?.maxToolRounds ?? 20; // configurable via ModelConfig, default 10
let lastToolResults: string[] = [];
// Convert tools to OpenAI format
const openAITools = this.convertToolsToOpenAIFormat(tools);
for (let round = 0; round < maxRounds; round++) {
// Apply delay before each API call to prevent rate limiting
if (round > 0) { // Skip delay on first call
await this.applyRequestDelay(logger);
}
const completion = await this.client.chat.completions.create({
model: this.model,
messages,
tools: openAITools,
tool_choice: "auto", // Let model decide when to use tools
...this.getOpenRouterOptions(),
});
const response = completion.choices[0]?.message;
if (!response) {
if (logger) {
logger.error("No response from OpenRouter API");
}
return "Error: No response from OpenRouter API";
}
// Debug: log the response structure
if (logger) {
logger.debug("OpenRouter response structure", {
hasToolCalls: !!response.tool_calls,
toolCallsLength: response.tool_calls?.length || 0,
messageContent: response.content?.substring(0, 200),
finishReason: completion.choices[0]?.finish_reason,
});
}
// Handle error finish reasons
const finishReason = completion.choices[0]?.finish_reason;
if (finishReason === "error") {
const errorMsg = `OpenRouter API returned error finish reason. Content: ${response.content || "No content"}`;
if (logger) {
logger.error(errorMsg, {
model: this.model,
fullResponse: completion,
});
}
return `Error: ${errorMsg}`;
}
// Check if model wants to use tools
if (response.tool_calls && response.tool_calls.length > 0) {
if (logger) {
logger.debug("Model requested tool calls", {
toolCalls: response.tool_calls.map(tc => ({
id: tc.id,
tool: tc.function.name,
args: truncate(tc.function.arguments, 200),
})),
});
}
const toolResults: string[] = [];
// Execute each tool call
for (const toolCall of response.tool_calls) {
if (logger) {
logger.debug("Executing tool", {
toolId: toolCall.id,
tool: toolCall.function.name,
args: truncate(toolCall.function.arguments, 200),
});
}
try {
// Parse arguments if they're a string
let args = toolCall.function.arguments;
if (typeof args === "string") {
args = JSON.parse(args);
}
const result = await executeTool(
toolCall.function.name,
args as unknown as Record<string, any>,
agent,
logger,
);
if (logger) {
logger.debug("Tool executed successfully", {
toolId: toolCall.id,
tool: toolCall.function.name,
});
}
toolResults.push(
`Tool ${toolCall.function.name} (${toolCall.id}) args: ${JSON.stringify(args)}
Result: ${JSON.stringify(result, null, 2)}`,
);
// Add tool result message for OpenRouter (exclude large data like screenshots)
const contextResult = this.sanitizeToolResultForContext(result);
messages.push({
role: "tool" as const,
tool_call_id: toolCall.id,
content: JSON.stringify(contextResult),
});
} catch (error: any) {
if (logger) {
logger.error("Tool execution failed", {
toolId: toolCall.id,
tool: toolCall.function.name,
args: toolCall.function.arguments,
error: error.message,
});
}
toolResults.push(
`Tool ${toolCall.function.name} (${toolCall.id}) args: ${toolCall.function.arguments}
Error: ${error.message}`,
);
// Add error as tool result
messages.push({
role: "tool" as const,
tool_call_id: toolCall.id,
content: `Error: ${error.message}`,
});
}
}
lastToolResults = toolResults;
if (logger) {
logger.debug("Sending tool results back to LLM for follow-up", { round: round + 1 });
}
// Add assistant message with tool calls to conversation history
messages.push(response);
// Continue to next round to let the model produce final content or request more tools
continue;
}
// No tool calls -> final answer
if (logger) {
logger.debug("Final response generated after tool execution", {
round: round + 1,
contentPreview: response.content?.substring(0, 200),
finishReason: completion.choices[0]?.finish_reason,
});
}
const finalContent = response.content || "";
if (this.modelConfig?.appendToolResults && lastToolResults.length > 0) {
return `${finalContent}
---
Raw tool results:
${lastToolResults.join("\n\n")}`;
}
return finalContent;
}
// Safety fallback if max rounds reached
if (logger) {
logger.debug("Max tool rounds reached, returning basic response");
}
// Remove tool-related messages and try basic generation
const basicMessages = messages.filter(m => m.role !== "tool");
await this.applyRequestDelay(logger); // Apply delay before fallback call
const fallbackCompletion = await this.client.chat.completions.create({
model: this.model,
messages: basicMessages,
...this.getOpenRouterOptions(),
});
return fallbackCompletion.choices[0]?.message?.content || "";
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (logger) {
logger.error(`OpenRouter provider error: ${errorMessage}`);
}
return `Error: OpenRouter provider error - ${errorMessage}`;
}
}
/**
* Chat with tool support
* @param messages - Array of messages for the conversation
* @param tools - Array of tool definitions
* @param logger - Optional logger for debugging
* @param agent - Optional agent instance for MCP tool execution
* @returns Promise with the model's response after tool execution
*/
async chatWithTools(
messages: Array<{ role: string; content: string }>,
tools: Tool[],
logger?: AgentForceLogger,
agent?: any,
): Promise<string> {
try {
if (logger) {
logger.debug("Initial OpenRouter chat call with tools", {
model: this.model,
toolsAvailable: tools.map(t => t.function.name),
messageCount: messages.length,
});
}
// Convert messages to OpenAI format
const convo: ChatCompletionMessageParam[] = messages.map(msg => {
const baseMsg = {
role: msg.role as "system" | "user" | "assistant" | "tool",
content: msg.content,
};
// Tool messages need tool_call_id, but we don't have it from input messages
// So we filter out any tool messages from the initial conversion
if (msg.role === "tool") {
// Skip tool messages in initial conversion - they'll be added during tool execution
return null;
}
return baseMsg;
}).filter(msg => msg !== null) as ChatCompletionMessageParam[];
const maxRounds = this.modelConfig?.maxToolRounds ?? 20;
let lastToolResults: string[] = [];
// Convert tools to OpenAI format
const openAITools = this.convertToolsToOpenAIFormat(tools);
for (let round = 0; round < maxRounds; round++) {
// Apply delay before each API call to prevent rate limiting
if (round > 0) { // Skip delay on first call
await this.applyRequestDelay(logger);
}
const completion = await this.client.chat.completions.create({
model: this.model,
messages: convo,
tools: openAITools,
tool_choice: "auto",
...this.getOpenRouterOptions(),
});
const response = completion.choices[0]?.message;
if (!response) {
if (logger) {
logger.error("No response from OpenRouter API");
}
return "Error: No response from OpenRouter API";
}
// Check if model wants to use tools
if (response.tool_calls && response.tool_calls.length > 0) {
if (logger) {
logger.debug("Model requested tool calls", {
toolCalls: response.tool_calls.map(tc => ({
id: tc.id,
tool: tc.function.name,
args: truncate(tc.function.arguments, 200),
})),
});
}
const toolResults: string[] = [];
// Add assistant message with tool calls first
convo.push(response);
// Execute each tool call
for (const toolCall of response.tool_calls) {
if (logger) {
logger.debug("Executing tool", {
toolId: toolCall.id,
tool: toolCall.function.name,
args: truncate(toolCall.function.arguments, 200),
});
}
try {
// Parse arguments if they're a string
let args = toolCall.function.arguments;
if (typeof args === "string") {
args = JSON.parse(args);
}
const result = await executeTool(
toolCall.function.name,
args as unknown as Record<string, any>,
agent,
logger,
);
if (logger) {
logger.debug("Tool executed successfully", {
toolId: toolCall.id,
tool: toolCall.function.name,
});
}
toolResults.push(
`Tool ${toolCall.function.name} (${toolCall.id}) args: ${JSON.stringify(args)}
Result: ${JSON.stringify(result, null, 2)}`,
);
// Add tool result message to conversation (exclude large data like screenshots)
const contextResult = this.sanitizeToolResultForContext(result);
convo.push({
role: "tool" as const,
tool_call_id: toolCall.id,
content: JSON.stringify(contextResult),
});
} catch (error: any) {
if (logger) {
logger.error("Tool execution failed", {
toolId: toolCall.id,
tool: toolCall.function.name,
args: toolCall.function.arguments,
error: error.message,
});
}
toolResults.push(
`Tool ${toolCall.function.name} (${toolCall.id}) args: ${toolCall.function.arguments}
Error: ${error.message}`,
);
// Add error as tool result
convo.push({
role: "tool" as const,
tool_call_id: toolCall.id,
content: `Error: ${error.message}`,
});
}
}
lastToolResults = toolResults;
// Continue to next round
continue;
}
// No tool calls -> final answer
if (logger) {
logger.debug("Final response generated after tool execution", {
round: round + 1,
contentPreview: response.content?.substring(0, 200),
finishReason: completion.choices[0]?.finish_reason,
});
}
const finalContent = response.content || "";
if (this.modelConfig?.appendToolResults && lastToolResults.length > 0) {
return `${finalContent}
---
Raw tool results:
${lastToolResults.join("\n\n")}`;
}
return finalContent;
}
if (logger) {
logger.debug("Max tool rounds reached, returning last attempt content");
}
// Remove tool messages and try basic chat
const basicMessages = convo.filter(m => m.role !== "tool");
await this.applyRequestDelay(logger); // Apply delay before final attempt
const lastAttempt = await this.client.chat.completions.create({
model: this.model,
messages: basicMessages,
...this.getOpenRouterOptions(),
});
return lastAttempt.choices[0]?.message?.content || "";
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (logger) {
logger.error(`OpenRouter provider error: ${errorMessage}`);
}
return `Error: OpenRouter provider error - ${errorMessage}`;
}
}
}