logancyang_obsidian-copilot/src/LLMProviders/chainRunner/utils/toolExecution.ts
Zero Liu 6ca2dc01ea
chore(eslint): enable no-explicit-any; fix ~395 violations (#2452)
* chore(eslint): enable no-explicit-any; fix ~395 violations

Switches @typescript-eslint/no-explicit-any from "off" to "error" and
replaces explicit `any` with proper types or `unknown` + narrowing
across ~100 source files and 15 test files. Eleven eslint-disable
comments remain: one for a BaseLanguageModel prototype patch and ten
for Orama<any> API surfaces where typed alternatives poison Orama's
internal inference to `never`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scripts): cast chainContext to ChainManager in printPromptDebugEntry

The earlier `as any` → `as unknown` rewrite left buildAgentPromptDebugReport's
chainManager argument typed as `unknown`, which CI's `tsc -noEmit` (without
`--skipLibCheck`) flagged. Restore the cast through the real ChainManager type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): fix remaining no-explicit-any and unnecessary-type-assertion errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): drop redundant no-explicit-any rule

Already set to "error" by typescript-eslint/recommendedTypeChecked via
obsidianmd's recommended config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:08:45 -07:00

307 lines
9.3 KiB
TypeScript

import { StructuredTool } from "@langchain/core/tools";
import { logError, logInfo, logWarn } from "@/logger";
import { checkIsPlusUser, isSelfHostModeValid } from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { ToolManager } from "@/tools/toolManager";
import { ToolRegistry } from "@/tools/ToolRegistry";
import { err2String } from "@/utils";
/**
* Represents a tool call with name and arguments.
* Used by native tool calling flow.
*/
export interface ToolCall {
name: string;
args: Record<string, unknown>;
}
export interface ToolExecutionResult {
toolName: string;
result: string;
success: boolean;
/**
* Optional display-friendly version of the tool result for UI rendering.
* When absent, fallback to `result` for display purposes.
*/
displayResult?: string;
}
/**
* Executes a single tool call with timeout and error handling
*/
export async function executeSequentialToolCall(
toolCall: ToolCall,
availableTools: Pick<StructuredTool, "name" | "invoke">[],
originalUserMessage?: string
): Promise<ToolExecutionResult> {
const DEFAULT_TOOL_TIMEOUT = 120000; // 120 seconds timeout per tool
try {
// Validate tool call
if (!toolCall || !toolCall.name) {
return {
toolName: toolCall?.name || "unknown",
result: "Error: Invalid tool call - missing tool name",
success: false,
};
}
// Find the tool in the existing tool registry
const tool = availableTools.find((t) => t.name === toolCall.name);
if (!tool) {
const availableToolNames = availableTools.map((t): string => t.name).join(", ");
return {
toolName: toolCall.name,
result: `Error: Tool '${toolCall.name}' not found. Available tools: ${availableToolNames}. Make sure you have the tool enabled in the Agent settings.`,
success: false,
};
}
// Get tool metadata from registry
const registry = ToolRegistry.getInstance();
const metadata = registry.getToolMetadata(toolCall.name);
// Check if tool requires Plus subscription
if (metadata?.isPlusOnly) {
const isPlusUser = await checkIsPlusUser();
if (!isPlusUser && !isSelfHostModeValid()) {
return {
toolName: toolCall.name,
result: `Error: ${getToolDisplayName(toolCall.name)} requires a Copilot Plus subscription`,
success: false,
};
}
}
// Prepare tool arguments
const toolArgs = { ...toolCall.args };
// If tool requires user message content and it's provided, inject it
if (metadata?.requiresUserMessageContent && originalUserMessage) {
toolArgs._userMessageContent = originalUserMessage;
}
// Determine timeout for this tool
let timeout = DEFAULT_TOOL_TIMEOUT;
if (typeof metadata?.timeoutMs === "number") {
timeout = metadata.timeoutMs;
}
let result;
if (!timeout || timeout === Infinity) {
// No timeout for this tool
result = await ToolManager.callTool(tool, toolArgs);
} else {
// Use timeout
result = await Promise.race([
ToolManager.callTool(tool, toolArgs),
new Promise((_, reject) =>
window.setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeout}ms`)),
timeout
)
),
]);
}
// Validate result
if (result === null || result === undefined) {
logWarn(`Tool ${toolCall.name} returned null/undefined result`);
// Return empty JSON object instead of plain string for better compatibility
return {
toolName: toolCall.name,
result: JSON.stringify({
message: "Tool executed but returned no result",
status: "empty",
}),
success: true,
};
}
return {
toolName: toolCall.name,
result: typeof result === "string" ? result : JSON.stringify(result),
success: true,
};
} catch (error) {
// Log actionable error with args for debugging schema mismatches
const errorMsg = err2String(error);
const isSchemaError = errorMsg.includes("schema");
if (isSchemaError) {
logError(
`[ToolCall] Schema validation failed for "${toolCall.name}". Args: ${JSON.stringify(toolCall.args, null, 2)}`
);
} else {
logError(`[ToolCall] Error executing "${toolCall.name}": ${errorMsg}`);
}
return {
toolName: toolCall.name,
result: `Error: ${errorMsg}`,
success: false,
};
}
}
/**
* Get display name for tool (user-friendly version)
*/
export function getToolDisplayName(toolName: string): string {
// Special handling for localSearch to show the actual search type being used
if (toolName === "localSearch") {
const settings = getSettings();
return settings.enableSemanticSearchV3
? "vault search (semantic)"
: "vault search (index-free)";
}
const displayNameMap: Record<string, string> = {
webSearch: "web search",
getFileTree: "file tree",
getCurrentTime: "current time",
getTimeRangeMs: "time range",
getTimeInfoByEpoch: "time info",
convertTimeBetweenTimezones: "timezone converter",
startPomodoro: "pomodoro timer",
pomodoroTool: "pomodoro timer",
youtubeTranscription: "YouTube transcription",
indexVault: "vault indexing",
indexTool: "index",
writeFile: "file editor",
editFile: "file editor",
obsidianDailyNote: "daily note (CLI)",
obsidianRandomRead: "random note (CLI)",
obsidianProperties: "properties (CLI)",
obsidianTasks: "tasks (CLI)",
obsidianLinks: "links (CLI)",
obsidianTemplates: "templates (CLI)",
obsidianBases: "bases (CLI)",
};
return displayNameMap[toolName] || toolName;
}
/**
* Get emoji for tool display
*/
export function getToolEmoji(toolName: string): string {
const emojiMap: Record<string, string> = {
localSearch: "🔍",
webSearch: "🌐",
getFileTree: "📁",
getCurrentTime: "🕒",
getTimeRangeMs: "📅",
getTimeInfoByEpoch: "🕰️",
convertTimeBetweenTimezones: "🌍",
youtubeTranscription: "📺",
indexVault: "📚",
indexTool: "📚",
writeFile: "✏️",
editFile: "🔄",
readNote: "🔍",
obsidianDailyNote: "📅",
obsidianRandomRead: "🎲",
obsidianProperties: "🏷️",
obsidianTasks: "✅",
obsidianLinks: "🔗",
obsidianTemplates: "📄",
obsidianBases: "🗄️",
};
return emojiMap[toolName] || "🔧";
}
/**
* Get user confirmation message for tool call
*/
export function getToolConfirmtionMessage(
toolName: string,
toolArgs?: Record<string, unknown>
): string | null {
if (toolName == "writeFile" || toolName == "editFile") {
return "Accept / reject in the Preview";
}
// Display salient terms for lexical search
if (toolName === "localSearch" && toolArgs?.salientTerms) {
const settings = getSettings();
// Only show salient terms for lexical search (index-free)
if (!settings.enableSemanticSearchV3) {
const terms = Array.isArray(toolArgs.salientTerms) ? toolArgs.salientTerms : [];
if (terms.length > 0) {
return `Terms: ${terms.slice(0, 3).join(", ")}${terms.length > 3 ? "..." : ""}`;
}
}
}
return null;
}
/**
* Log tool call details for debugging
*/
export function logToolCall(toolCall: ToolCall, iteration: number): void {
const displayName = getToolDisplayName(toolCall.name);
const emoji = getToolEmoji(toolCall.name);
// Create clean parameter display
const paramDisplay =
Object.keys(toolCall.args).length > 0
? JSON.stringify(toolCall.args, null, 2)
: "(no parameters)";
logInfo(`${emoji} [Iteration ${iteration}] ${displayName.toUpperCase()}`);
logInfo(`Parameters:`, paramDisplay);
logInfo("---");
}
/**
* Log tool execution result
*/
export function logToolResult(toolName: string, result: ToolExecutionResult): void {
// For localSearch we already emit a structured table elsewhere; avoid redundant logs entirely
if (toolName === "localSearch") {
return;
}
const displayName = getToolDisplayName(toolName);
const emoji = getToolEmoji(toolName);
const status = result.success ? "✅ SUCCESS" : "❌ FAILED";
logInfo(`${emoji} ${displayName.toUpperCase()} RESULT: ${status}`);
// Default: log abbreviated result for readability (cap at 300 chars)
const maxLogLength = 300;
const text = String(result.result ?? "");
if (text.length > maxLogLength) {
logInfo(
`Result: ${text.substring(0, maxLogLength)}... (truncated, ${text.length} chars total)`
);
} else if (text.length > 0) {
logInfo(`Result:`, text);
}
}
/**
* Deduplicate sources by path, keeping highest score
* If path is not available, falls back to title
*/
export function deduplicateSources(
sources: { title: string; path: string; score: number; explanation?: unknown }[]
): { title: string; path: string; score: number; explanation?: unknown }[] {
const uniqueSources = new Map<
string,
{ title: string; path: string; score: number; explanation?: unknown }
>();
for (const source of sources) {
// Use path as the unique key, falling back to title if path is not available
const key = source.path || source.title;
const existing = uniqueSources.get(key);
if (!existing || source.score > existing.score) {
uniqueSources.set(key, source);
}
}
return Array.from(uniqueSources.values()).sort((a, b) => b.score - a.score);
}