mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* 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>
307 lines
9.3 KiB
TypeScript
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);
|
|
}
|