shitton of the ai changes

This commit is contained in:
2026-05-01 04:54:11 +03:00
parent d95c37a322
commit 8cff086a8e
194 changed files with 29409 additions and 8841 deletions
+91
View File
@@ -0,0 +1,91 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
const logger = toolsLogger.child("create-note");
export type CreateNoteResult =
| { success: true; filePath: string }
| { success: false; error: string };
export const createNoteTool = {
type: "function",
function: {
name: "create_note",
description: "Create a new Markdown note with a valid file name, optional title, and Markdown-formatted content.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description: "The valid file name for the note. It must be suitable for use as a file name and must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. Use a clear, concise name based on the note topic. Include the .md extension if the user provides it or if Markdown files are expected."
},
title: {
type: "string",
description: "The title of the note. Use a concise, human-readable title based on the user's request or the note content."
},
content: {
type: "string",
description: "The full content of the note formatted as valid Markdown. Preserve existing Markdown formatting when provided. If the source content has little or no formatting, add appropriate Markdown structure such as headings, paragraphs, lists, links, code blocks, tables, or emphasis where useful, without changing the meaning."
}
},
required: ["fileName", "content"],
}
}
} satisfies AiTool;
export async function createNote(
args?: AiJsonObject
): Promise<CreateNoteResult> {
const startedAt = Date.now();
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const title = asNonEmptyString(args?.title) ?? fileName;
const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) {
return {success: false, error: "No content provided"};
}
const newFilePath = path.join(notesDir, fileName.endsWith(".md") ? fileName : fileName + ".md");
const linkMarkdown = `* [${title}](${path.relative(path.dirname(notesRootFile), newFilePath)})`;
try {
if (fs.existsSync(newFilePath)) {
return {success: false, error: "File already exists"};
}
await writeFile(newFilePath, content, "utf-8");
let rootContent: string;
try {
rootContent = await readFile(notesRootFile, "utf-8");
} catch (e) {
rootContent = "";
}
const notesHeaderIndex = rootContent.indexOf(NOTES_HEADER);
if (notesHeaderIndex >= 0) {
rootContent += "\n" + linkMarkdown;
} else {
rootContent = NOTES_HEADER + "\n" + linkMarkdown;
}
await writeFile(notesRootFile, rootContent, "utf-8");
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: newFilePath};
} catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to process files: ${errorMessage}`};
}
}
+93
View File
@@ -0,0 +1,93 @@
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
import {AiJsonObject} from "../tool-types";
export const getCurrentDateTimeTool = {
type: "function",
function: {
name: "get_datetime",
description:
"Get the real current date and time. Use this tool before answering any request that depends on today, now, current time, current date, weekday, timestamp, timezone conversion, or relative dates like yesterday, tomorrow, next week, or 3 days ago.",
parameters: {
type: "object",
properties: {
timeZone: {
type: "string",
description:
"Optional IANA timezone, for example Europe/Moscow, Europe/Berlin, UTC. If omitted, system timezone is used.",
},
locale: {
type: "string",
description:
"Optional locale, for example ru-RU or en-US. If omitted, system locale/default locale is used.",
},
},
required: [],
},
},
} satisfies AiTool;
export const dateTimeToolPrompt = [
"Datetime tool rules:",
"- Use `get_datetime` whenever the answer depends on the real current date/time.",
"- Never guess the current date/time. Call the tool first.",
"",
"Arguments:",
"- `timeZone`: optional IANA timezone, e.g. `Europe/Moscow`, `Europe/Berlin`, `UTC`.",
"- `locale`: optional locale, e.g. `ru-RU`, `en-US`.",
"",
"After the tool returns:",
"- Base the answer on the returned value.",
"- Do not expose raw tool JSON unless asked.",
].join("\n");
function getSystemTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function getCurrentDateTime(args?: AiJsonObject) {
const now = new Date();
const systemTimeZone = getSystemTimeZone();
const requestedTimeZone = asNonEmptyString(args?.timeZone);
const requestedLocale = asNonEmptyString(args?.locale);
const timeZone = requestedTimeZone ?? systemTimeZone;
const locale = requestedLocale ?? undefined;
try {
const formatted = new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone,
systemTimeZone,
locale: locale ?? "system-default",
formatted,
};
} catch (error) {
const formatted = new Intl.DateTimeFormat(undefined, {
timeZone: systemTimeZone,
dateStyle: "full",
timeStyle: "long",
}).format(now);
return {
iso: now.toISOString(),
unixMs: now.getTime(),
timeZone: systemTimeZone,
systemTimeZone,
locale: "system-default",
formatted,
warning: "Invalid locale or timezone was provided. Fallback to system locale and system timezone was used.",
requestedTimeZone: requestedTimeZone ?? null,
requestedLocale: requestedLocale ?? null,
error: error instanceof Error ? error.message : String(error),
};
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
export const MAX_FILE_READ_BYTES = 128 * 1024 * 1024;
export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
export const MAX_DIRECTORY_ENTRIES = 200;
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_COPY_ENTRIES = 500;
export const MAX_PATCH_OPERATIONS = 20;
export const MAX_PATCH_SEARCH_BYTES = 64 * 1024;
export const MAX_PATCH_REPLACE_BYTES = 256 * 1024;
export const MAX_PATCH_PREVIEW_CHARS = 6000;
export const MAX_FILE_SEARCH_ENTRIES = 5000;
export const MAX_FILE_SEARCH_RESULTS = 100;
export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024;
export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300;
export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024;
export const MAX_STREAM_WRITE_SESSIONS = 20;
export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000;
export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
+78
View File
@@ -0,0 +1,78 @@
import {AiTool} from "../tool-types";
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
const logger = toolsLogger.child("market-rates");
export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data";
export const getFinancialMarketData = {
type: "function",
function: {
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
description:
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const getFinancialMarketDataToolPrompt = [
"Currency rates tool rules:",
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`,
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`,
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
"",
"Supported pairs:",
"- `usd_to_rub`: USD to RUB.",
"- `usd_to_eur`: USD to EUR.",
"- `usd_to_kzt`: USD to KZT.",
"- `usd_to_uah`: USD to UAH.",
"- `usd_to_byn`: USD to BYN.",
"- `usd_to_gbp`: USD to GBP.",
"- `usd_to_cny`: USD to CNY.",
"- `ton_to_usd`: TON to USD.",
"- `btc_to_usd`: BTC to USD.",
"- `eth_to_usd`: ETH to USD.",
"- `sol_to_usd`: SOL to USD.",
"- `xau_to_usd`: gold/XAU to USD.",
"",
"Arguments:",
"- This tool takes no arguments.",
"",
"Returned data:",
"- Each supported pair contains `rate` with the latest available rate.",
"- Each supported pair may contain `change.absolute` with the absolute 24-hour change.",
"- Each supported pair may contain `change.percent` with the percentage 24-hour change.",
"- Each supported pair may contain `change.direction` with the movement direction, e.g. `up`, `down`, or `flat`.",
"- `has_24h_comparison`: whether 24-hour comparison data is available.",
"",
"After the tool returns:",
"- Base the answer only on the returned values.",
"- If `has_24h_comparison` is false, provide only the current rates and say that 24-hour comparison is unavailable.",
"- Do not expose raw tool JSON unless asked.",
"- Format the answer in a user-friendly way.",
"- For fiat pairs, show the rate with the target currency, for example: `USD/RUB is 75.22 RUB, down 0.16% over 24 hours.`",
"- For crypto and gold pairs, show the USD price, for example: `BTC/USD is $81,451.66, up 0.22% over 24 hours.`",
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
].join("\n");
export async function getMarketRates(): Promise<AiJsonObject | undefined> {
const startedAt = Date.now();
try {
logger.info("start");
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
return response.data;
} catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
return undefined;
}
}
+449
View File
@@ -0,0 +1,449 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {z} from "zod";
import {AiJsonObject} from "../tool-types";
const logger = toolsLogger.child("notes");
export type NoteListItem = {
fileName: string;
filePath: string;
relativePath: string;
title: string;
};
export type ListNotesResult =
| { success: true; notes: NoteListItem[] }
| { success: false; error: string };
export type GetNoteContentResult =
| {
success: true;
fileName: string;
filePath: string;
relativePath: string;
title: string;
content: string;
} | { success: false; error: string };
export const listNotesTool = {
type: "function",
function: {
name: "list_notes",
description: "Display all available Markdown notes from the notes directory.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
} satisfies AiTool;
export const getNoteContentTool = {
type: "function",
function: {
name: "get_note_content",
description: "Get the full Markdown content of a specific note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to read. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function listNotes(): Promise<ListNotesResult> {
const startedAt = Date.now();
logger.debug("list.start");
try {
const entries = await readdir(notesDir, {withFileTypes: true});
const markdownFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md") && !fileName.startsWith("index"));
const notes: NoteListItem[] = await Promise.all(
markdownFiles.map(async (fileName) => {
const filePath = path.join(notesDir, fileName);
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
let content = "";
try {
content = await readFile(filePath, "utf-8");
} catch {
// Ignore content read errors for individual files.
}
return {
fileName,
filePath,
relativePath,
title: extractNoteTitle(fileName, content),
};
}),
);
notes.sort((a, b) => a.title.localeCompare(b.title));
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`};
}
}
export async function getNoteContent(
args?: AiJsonObject,
): Promise<GetNoteContentResult> {
const startedAt = Date.now();
logger.debug("get_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to access `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
const content = await readFile(noteFilePath, "utf-8");
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", {
fileName: normalizedFileName,
relativePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return {
success: true,
fileName: normalizedFileName,
filePath: noteFilePath,
relativePath,
title: extractNoteTitle(normalizedFileName, content),
content,
};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`};
}
}
function extractNoteTitle(fileName: string, content: string): string {
const headingMatch = content.match(/^#\s+(.+)$/m);
const heading = headingMatch?.[1]?.trim();
if (heading) {
return heading;
}
return path.basename(fileName, ".md");
}
export function buildSafeNoteFilePath(fileName: string): string | null {
const normalizedFileName = fileName.endsWith(".md") ? fileName : `${fileName}.md`;
if (!normalizedFileName.trim().length) {
return null;
}
const unsafeFileNamePattern = /[/\\:*?"<>|\x00-\x1F]/;
if (unsafeFileNamePattern.test(normalizedFileName)) {
return null;
}
const resolvedNotesDir = path.resolve(notesDir);
const resolvedFilePath = path.resolve(notesDir, normalizedFileName);
if (!resolvedFilePath.startsWith(resolvedNotesDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
export type UpdateNoteContentResult =
| { success: true; filePath: string }
| { success: false; error: string };
export type DeleteNoteResult =
| { success: true; filePath: string }
| { success: false; error: string };
export const updateNoteContentTool = {
type: "function",
function: {
name: "update_note_content",
description: "Update the full Markdown content of an existing note by its file name.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to update. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
content: {
type: "string",
description:
"The new full content of the note formatted as valid Markdown. This replaces the previous content completely.",
},
},
required: ["fileName", "content"],
},
},
} satisfies AiTool;
export const deleteNoteTool = {
type: "function",
function: {
name: "delete_note",
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present. It is forbidden to delete/edit/rename `index.md` note.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. It is forbidden to delete/edit/rename `index.md` note.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function updateNoteContent(
args?: AiJsonObject,
): Promise<UpdateNoteContentResult> {
const startedAt = Date.now();
logger.debug("update_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to edit `index.md`"};
}
const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) {
return {success: false, error: "No content provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", {
fileName,
filePath: noteFilePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`};
}
}
export async function deleteNote(
args?: AiJsonObject,
): Promise<DeleteNoteResult> {
const startedAt = Date.now();
logger.debug("delete.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to delete `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
await unlink(noteFilePath);
await removeNoteLinkFromRoot(noteFilePath);
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`};
}
}
async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
let rootContent: string;
try {
rootContent = await readFile(notesRootFile, "utf-8");
} catch {
return;
}
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const normalizedRelativePath = relativePath.replaceAll("\\", "\\\\");
const escapedRelativePath = escapeRegExp(normalizedRelativePath);
const linkLinePattern = new RegExp(
`^\\s*[-*]\\s+\\[[^\\]]+]\\(${escapedRelativePath}\\)\\s*$\\n?`,
"gm",
);
const updatedRootContent = rootContent.replace(linkLinePattern, "");
if (updatedRootContent !== rootContent) {
await writeFile(notesRootFile, updatedRootContent.trimEnd() + "\n", "utf-8");
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export type NoteFileAttachment = {
type: "local_file";
fileName: string;
// filePath: string;
relativePath: string;
mimeType: "text/markdown";
sizeBytes: number;
};
export type GetNoteFileResult =
| {
success: true;
attachment: NoteFileAttachment;
} | { success: false; error: string };
export const NoteFileAttachmentSchema = z.object({
type: z.literal("local_file"),
fileName: z.string(),
// filePath: z.string(),
relativePath: z.string(),
mimeType: z.literal("text/markdown"),
sizeBytes: z.number(),
});
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
attachment: NoteFileAttachmentSchema,
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
export const sendNoteAsFileTool = {
type: "function",
function: {
name: "send_note_as_file",
description:
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function sendNoteAsFile(
args?: AiJsonObject,
): Promise<GetNoteFileResult> {
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
// Проверяем, что файл существует и действительно читается.
await readFile(noteFilePath, "utf-8");
const fileStat = await stat(noteFilePath);
if (!fileStat.isFile()) {
return {success: false, error: "Note path is not a file"};
}
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const result: GetNoteFileResult = {
success: true,
attachment: {
type: "local_file",
fileName: normalizedFileName,
// filePath: noteFilePath,
relativePath,
mimeType: "text/markdown",
sizeBytes: fileStat.size,
},
};
logger.debug("done", {
fileName: result.attachment.fileName,
relativePath: result.attachment.relativePath,
sizeBytes: result.attachment.sizeBytes
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
}
}
+822
View File
@@ -0,0 +1,822 @@
import {spawn} from "node:child_process";
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment";
import {toolsLogger} from "./tool-logger";
import {randomUUID} from "node:crypto";
import {AiJsonObject} from "../tool-types";
const logger = toolsLogger.child("python-interpreter");
export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter";
export type PythonInterpreterArgs = {
/**
* Full Python 3 script.
* The model should use print(...) to expose useful output.
*/
code: string;
/**
* Optional stdin passed to the Python process.
*/
stdin?: string;
/**
* Optional timeout override.
*/
timeoutMs?: number;
};
export type PythonInterpreterOptions = {
pythonBinary?: string;
syntaxTimeoutMs?: number;
executionTimeoutMs?: number;
maxCodeChars?: number;
maxOutputChars?: number;
maxArtifactBytes?: number;
maxArtifactCount?: number;
inputFiles?: PythonInterpreterInputFile[];
};
type ProcessRunResult = {
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
outputTruncated: boolean;
durationMs: number;
};
export type PythonInterpreterInputFile = {
kind?: string;
path: string;
fileName: string;
mimeType?: string;
};
export type PythonInterpreterRuntimeInputFile = PythonInterpreterInputFile & {
index: number;
path: string;
sourcePath: string;
relativePath: string;
sizeBytes: number;
};
export type PythonInterpreterArtifact = {
kind: "image" | "file";
path: string;
relativePath: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
};
export type PythonInterpreterSkippedArtifact = {
path: string;
relativePath: string;
fileName: string;
sizeBytes?: number;
reason: string;
maxSizeBytes?: number;
};
export type PythonToolResult =
| {
ok: true;
phase: "execution";
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
outputTruncated: boolean;
inputDir?: string;
outputDir?: string;
inputFiles?: PythonInterpreterRuntimeInputFile[];
artifacts?: PythonInterpreterArtifact[];
skippedArtifacts?: PythonInterpreterSkippedArtifact[];
}
| {
ok: false;
phase: "syntax" | "execution" | "internal";
error: string;
stdout?: string;
stderr?: string;
exitCode?: number | null;
signal?: NodeJS.Signals | null;
timedOut?: boolean;
durationMs?: number;
outputTruncated?: boolean;
};
const DEFAULT_PYTHON_BINARY = process.platform === "win32" ? "python" : "python3";
const DEFAULT_SYNTAX_TIMEOUT_MS = 3_000;
const DEFAULT_EXECUTION_TIMEOUT_MS = 8_000;
const DEFAULT_MAX_CODE_CHARS = 100_000;
const DEFAULT_MAX_OUTPUT_CHARS = 20_000;
export const PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
const DEFAULT_MAX_ARTIFACT_COUNT = 20;
const PYTHON_INPUTS_DIR_NAME = "inputs";
const PYTHON_OUTPUTS_DIR_NAME = "outputs";
const PYTHON_ATTACHMENTS_FILE_NAME = "attachments.json";
const PYTHON_USER_CODE_FILE_NAME = "user_code.py";
const PYTHON_RUNNER_FILE_NAME = "main.py";
const PYTHON_CODE_TEMPLATE = [
"from pathlib import Path",
"import json",
"",
"# These globals are predefined by the python_interpreter runtime:",
"# INPUT_DIR = Path('inputs')",
"# OUTPUT_DIR = Path('outputs')",
"# ATTACHMENTS_FILE = Path('attachments.json')",
"",
"attachments = load_attachments()",
"# Read attached files from INPUT_DIR, for example:",
"# text = (INPUT_DIR/attachments[0]['fileName']).read_text(encoding='utf-8')",
"",
"# Save every user-visible generated file into outputs.",
"# Example:",
"# (OUTPUT_DIR/'result.txt').write_text('done', encoding='utf-8')",
"",
"print('done')",
].join("\n");
export const pythonInterpreterToolPrompt = [
"Python interpreter rules:",
"- You have access to the `python_interpreter` tool for Python 3 code.",
"- Each Python run starts in a temporary workspace.",
"- Incoming user files are always in `inputs/`.",
"- Outgoing user-visible files must always be saved into `outputs/`.",
"- Attachment metadata is always in `attachments.json`.",
"- The runtime predefines these globals in executed code: `INPUT_DIR`, `OUTPUT_DIR`, `ATTACHMENTS_FILE`, `WORK_DIR`, `input_path(name)`, `output_path(name)`, and `load_attachments()`.",
"- Use `input_path(filename)` for reading incoming files.",
"- Use `output_path(filename)` for files that should be returned to the user.",
"- Do not invent other directories for user attachments or generated artifacts.",
"- Prefer this template:",
"```python",
PYTHON_CODE_TEMPLATE,
"```",
"",
].join("\n");
export const pythonInterpreterTool = {
type: "function",
function: {
name: PYTHON_INTERPRETER_TOOL_NAME,
description:
"Validate and execute short Python 3 code. Use for calculations, data transformations, parsing, chart rendering, and image/file processing. The code must print useful text results. The runtime always creates hardcoded directories `inputs/` and `outputs/` in the current working directory. User attachments are copied into `inputs/` and described in `attachments.json`. The executed code has predefined globals: INPUT_DIR, OUTPUT_DIR, ATTACHMENTS_FILE, WORK_DIR, input_path(name), output_path(name), and load_attachments(). Put every user-visible output image or file into `outputs/`; every regular file there up to 50 MB will be returned by the tool and sent to the user.",
parameters: {
type: "object",
required: ["code"],
properties: {
code: {
type: "string",
description:
`Complete Python 3 script to execute. Use print(...) for the final answer. Do not use markdown fences. Read incoming files only from INPUT_DIR / "file" or input_path("file"). Save charts/images/files intended for the user only into OUTPUT_DIR / "file" or output_path("file"). You can inspect attachments via load_attachments(). Template:\n${PYTHON_CODE_TEMPLATE}`,
},
stdin: {
type: "string",
description: "Optional stdin passed to the Python script.",
},
timeoutMs: {
type: "integer",
description: "Optional execution timeout in milliseconds. Default is 8000.",
},
},
},
},
} satisfies AiTool;
export async function runPythonInterpreter(
rawArgs: string | AiJsonObject | undefined,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
let args: PythonInterpreterArgs;
try {
args = parsePythonInterpreterArgs(rawArgs, options);
} catch (error) {
return {
ok: false,
phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)),
};
}
const syntaxStartedAt = Date.now();
const syntax = await validatePythonSyntax(args.code, options);
logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
if (!syntax.ok) {
return syntax;
}
const executionStartedAt = Date.now();
const result = await executePythonCode(args, options);
logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase});
return result;
}
export async function validatePythonSyntax(
code: string,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const pythonBinary = options.pythonBinary ?? DEFAULT_PYTHON_BINARY;
const timeoutMs = options.syntaxTimeoutMs ?? DEFAULT_SYNTAX_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const syntaxCheckScript = `
import ast
import sys
source = sys.stdin.read()
try:
ast.parse(source, filename="<llm_python>")
except SyntaxError as e:
print(f"SyntaxError: {e.msg} at line {e.lineno}, column {e.offset}", file=sys.stderr)
if e.text:
print(e.text.rstrip(), file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"{type(e).__name__}: {e}", file=sys.stderr)
sys.exit(1)
`.trim();
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", "-S", "-c", syntaxCheckScript],
input: code,
timeoutMs,
maxOutputChars,
env: buildSafeEnv(),
});
if (result.timedOut) {
return {
ok: false,
phase: "syntax",
error: `Python syntax check timed out after ${timeoutMs} ms.`,
stderr: result.stderr,
durationMs: result.durationMs,
timedOut: true,
outputTruncated: result.outputTruncated,
};
}
if (result.exitCode !== 0) {
return {
ok: false,
phase: "syntax",
error: result.stderr.trim() || "Python syntax check failed.",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
return {
ok: true,
phase: "execution",
stdout: "",
stderr: "",
exitCode: 0,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
async function executePythonCode(
args: PythonInterpreterArgs,
options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> {
const startedAt = Date.now();
logger.info("execute.start", {args, options});
const pythonBinary =
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "python";
const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME);
const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME);
const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME);
await mkdir(tempDir, {recursive: true});
await mkdir(inputDir, {recursive: true});
await mkdir(outputDir, {recursive: true});
const userScriptPath = path.join(tempDir, PYTHON_USER_CODE_FILE_NAME);
const runnerPath = path.join(tempDir, PYTHON_RUNNER_FILE_NAME);
try {
const inputFiles = await prepareInputFiles(options.inputFiles ?? [], inputDir);
await writeFile(attachmentsPath, JSON.stringify(inputFiles, null, 2), {
encoding: "utf8",
mode: 0o600,
});
await writeFile(userScriptPath, args.code, {
encoding: "utf8",
mode: 0o600,
});
await writeFile(runnerPath, buildPythonRunnerScript(), {
encoding: "utf8",
mode: 0o600,
});
logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)});
const result = await runProcess({
command: pythonBinary,
args: ["-I", "-B", runnerPath],
input: args.stdin ?? "",
cwd: tempDir,
timeoutMs,
maxOutputChars,
env: {
...buildSafeEnv(tempDir),
PYTHON_INPUT_DIR: inputDir,
PYTHON_OUTPUT_DIR: outputDir,
PYTHON_ATTACHMENTS_FILE: attachmentsPath,
},
});
logger.debug("process.done", {
duration: logger.duration(startedAt),
exitCode: result.exitCode,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated
});
if (result.timedOut) {
logger.warn("process.timeout", {duration: logger.duration(startedAt)});
return {
ok: false,
phase: "execution",
error: `Python execution timed out after ${timeoutMs} ms.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: true,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
if (result.outputTruncated) {
logger.warn("process.output_truncated", {
duration: logger.duration(startedAt),
stdoutChars: result.stdout.length,
stderrChars: result.stderr.length
});
return {
ok: false,
phase: "execution",
error: `Python output exceeded limit of ${maxOutputChars} characters.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: true,
};
}
if (result.exitCode !== 0) {
logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result});
return {
ok: false,
phase: "execution",
error: result.stderr.trim() || `Python exited with code ${result.exitCode}.`,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
signal: result.signal,
timedOut: false,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
};
}
logger.debug("process.ok", {duration: logger.duration(startedAt)});
const {
artifacts,
skippedArtifacts
} = await collectOutputArtifacts(outputDir, options);
return {
ok: true,
phase: "execution",
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
durationMs: result.durationMs,
outputTruncated: result.outputTruncated,
inputDir,
outputDir,
inputFiles,
artifacts,
skippedArtifacts,
};
} catch (error) {
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
return {
ok: false,
phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)),
};
} finally {
await rm(tempDir, {
recursive: true,
force: true,
});
}
}
function buildPythonRunnerScript(): string {
return `
import json
import runpy
from pathlib import Path
WORK_DIR = Path(__file__).resolve().parent
INPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_INPUTS_DIR_NAME)}
OUTPUT_DIR = WORK_DIR / ${JSON.stringify(PYTHON_OUTPUTS_DIR_NAME)}
ATTACHMENTS_FILE = WORK_DIR / ${JSON.stringify(PYTHON_ATTACHMENTS_FILE_NAME)}
USER_CODE_FILE = WORK_DIR / ${JSON.stringify(PYTHON_USER_CODE_FILE_NAME)}
INPUT_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def input_path(name=""):
return INPUT_DIR / name
def output_path(name=""):
return OUTPUT_DIR / name
def load_attachments():
if not ATTACHMENTS_FILE.exists():
return []
return json.loads(ATTACHMENTS_FILE.read_text(encoding="utf-8"))
runpy.run_path(
str(USER_CODE_FILE),
run_name="__main__",
init_globals={
"Path": Path,
"WORK_DIR": WORK_DIR,
"INPUT_DIR": INPUT_DIR,
"OUTPUT_DIR": OUTPUT_DIR,
"ATTACHMENTS_FILE": ATTACHMENTS_FILE,
"input_path": input_path,
"output_path": output_path,
"load_attachments": load_attachments,
},
)
`.trimStart();
}
async function prepareInputFiles(
inputFiles: PythonInterpreterInputFile[],
inputDir: string,
): Promise<PythonInterpreterRuntimeInputFile[]> {
const prepared: PythonInterpreterRuntimeInputFile[] = [];
for (const [index, file] of inputFiles.entries()) {
const sourcePath = path.resolve(file.path);
const info = await lstat(sourcePath).catch(() => null);
if (!info?.isFile()) continue;
const fileName = uniqueInputFileName(index, file.fileName || path.basename(sourcePath));
const runtimePath = path.join(inputDir, fileName);
await copyFile(sourcePath, runtimePath);
prepared.push({
...file,
index,
path: runtimePath,
sourcePath,
relativePath: path.join(PYTHON_INPUTS_DIR_NAME, fileName).replace(/\\/g, "/"),
sizeBytes: info.size,
fileName,
});
}
return prepared;
}
async function collectOutputArtifacts(
outputDir: string,
options: PythonInterpreterOptions,
): Promise<{
artifacts: PythonInterpreterArtifact[];
skippedArtifacts: PythonInterpreterSkippedArtifact[];
}> {
const maxBytes = options.maxArtifactBytes ?? PYTHON_INTERPRETER_MAX_ARTIFACT_BYTES;
const maxCount = options.maxArtifactCount ?? DEFAULT_MAX_ARTIFACT_COUNT;
const artifacts: PythonInterpreterArtifact[] = [];
const skippedArtifacts: PythonInterpreterSkippedArtifact[] = [];
const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, {withFileTypes: true}).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const info = await lstat(fullPath).catch(() => null);
if (!info) continue;
const relativePath = path.relative(outputDir, fullPath).replace(/\\/g, "/");
if (info.isSymbolicLink()) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName: safeFileName(entry.name),
reason: "Symbolic links are not returned.",
});
continue;
}
if (info.isDirectory()) {
await walk(fullPath);
continue;
}
if (!info.isFile()) continue;
const fileName = safeFileName(entry.name);
if (info.size > maxBytes) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `File exceeds the ${maxBytes} byte limit.`,
maxSizeBytes: maxBytes,
});
continue;
}
if (artifacts.length >= maxCount) {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: `Artifact count exceeds the ${maxCount} file limit.`,
});
continue;
}
const mimeType = mimeTypeFromPath(fullPath);
if (mimeType) {
artifacts.push({
kind: mimeType?.startsWith("image/") ? "image" : "file",
path: fullPath,
relativePath,
fileName,
mimeType,
sizeBytes: info.size,
});
} else {
skippedArtifacts.push({
path: fullPath,
relativePath,
fileName,
sizeBytes: info.size,
reason: "Unsupported mimeType for extension " + path.extname(fullPath)
});
}
}
};
await walk(outputDir);
return {artifacts, skippedArtifacts};
}
function safeFileName(value: string): string {
const sanitized = path.basename(value)
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_")
.trim()
.slice(0, 180);
return sanitized || "file";
}
function uniqueInputFileName(index: number, value: string): string {
const safe = safeFileName(value);
const ext = path.extname(safe);
const base = path.basename(safe, ext).slice(0, 140) || "input";
return `${index + 1}_${base}${ext}`;
}
function mimeTypeFromPath(filePath: string): string | undefined {
switch (path.extname(filePath).toLowerCase()) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".bmp":
return "image/bmp";
case ".svg":
return "image/svg+xml";
case ".pdf":
return "application/pdf";
case ".txt":
return "text/plain";
case ".csv":
return "text/csv";
case ".json":
return "application/json";
case ".zip":
return "application/zip";
case ".mp3":
return "audio/mpeg";
case ".wav":
return "audio/wav";
case ".mp4":
return "video/mp4";
default:
return undefined;
}
}
function parsePythonInterpreterArgs(
rawArgs: string | AiJsonObject | undefined,
options: PythonInterpreterOptions,
): PythonInterpreterArgs {
let args = rawArgs;
if (typeof rawArgs === "string") {
try {
args = JSON.parse(rawArgs);
} catch {
args = {code: rawArgs};
}
}
if (!args || typeof args !== "object" || Array.isArray(args)) {
throw new Error("Tool arguments must be an object.");
}
const record = args as AiJsonObject;
const code = record.code;
if (typeof code !== "string" || !code.trim()) {
throw new Error("Tool argument `code` must be a non-empty string.");
}
const maxCodeChars = options.maxCodeChars ?? DEFAULT_MAX_CODE_CHARS;
if (code.length > maxCodeChars) {
throw new Error(`Python code is too large: ${code.length} chars, max ${maxCodeChars}.`);
}
const stdin = record.stdin;
if (stdin !== undefined && typeof stdin !== "string") {
throw new Error("Tool argument `stdin` must be a string when provided.");
}
const timeoutMs = record.timeoutMs;
if (
timeoutMs !== undefined &&
(!Number.isInteger(timeoutMs) || Number(timeoutMs) < 100 || Number(timeoutMs) > 60_000)
) {
throw new Error("Tool argument `timeoutMs` must be an integer from 100 to 60000.");
}
return {
code,
stdin: typeof stdin === "string" ? stdin : undefined,
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
};
}
async function runProcess(params: {
command: string;
args: string[];
input?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs: number;
maxOutputChars: number;
}): Promise<ProcessRunResult> {
const startedAt = Date.now();
return new Promise<ProcessRunResult>((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let outputTruncated = false;
let settled = false;
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const finish = (result: Omit<ProcessRunResult, "durationMs">) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer) => {
const text = chunk.toString("utf8");
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
const total = stdout.length + stderr.length;
if (total > params.maxOutputChars) {
outputTruncated = true;
stdout = stdout.slice(0, params.maxOutputChars);
stderr = stderr.slice(0, params.maxOutputChars);
child.kill("SIGKILL");
}
};
child.stdout.on("data", (chunk: Buffer) => appendOutput("stdout", chunk));
child.stderr.on("data", (chunk: Buffer) => appendOutput("stderr", chunk));
child.on("error", (error) => {
finish({
exitCode: null,
signal: null,
stdout,
stderr: stderr + `\n${errorToString(error)}`,
timedOut,
outputTruncated,
});
});
child.on("close", (exitCode, signal) => {
finish({
exitCode,
signal,
stdout,
stderr,
timedOut,
outputTruncated,
});
});
child.stdin.end(params.input ?? "");
});
}
function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH ?? "",
PATHEXT: process.env.PATHEXT ?? "",
SystemRoot: process.env.SystemRoot ?? "",
HOME: tempDir ?? os.tmpdir(),
USERPROFILE: tempDir ?? os.tmpdir(),
TEMP: tempDir ?? os.tmpdir(),
TMP: tempDir ?? os.tmpdir(),
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
};
}
function errorToString(error: Error | string | object | null | undefined): string {
if (error instanceof Error) {
return error.stack || error.message;
}
return String(error);
}
+189
View File
@@ -0,0 +1,189 @@
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather";
import {
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
getFinancialMarketData,
getFinancialMarketDataToolPrompt,
getMarketRates
} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
import {
beginFileWrite,
beginFileWriteTool,
cancelFileWrite,
cancelFileWriteTool,
copyPath,
copyPathTool,
createDirectory,
createDirectoryTool,
createFile,
createFileTool,
deletePath,
deletePathTool,
editFilePatch,
editFilePatchTool,
fileToolsToolPrompt,
finishFileWrite,
finishFileWriteTool,
listDirectory,
listDirectoryTool,
readFile,
readFileTool,
renamePath,
renamePathTool,
searchFiles,
searchFilesTool,
sendFileAsAttachment,
sendFileAsAttachmentTool,
updateFile,
updateFileTool,
writeFileChunk,
writeFileChunkTool
} from "./files";
export const defaultTools: AiTool[] = [
getCurrentDateTimeTool,
getFinancialMarketData,
];
export const fileTools = [
readFileTool,
listDirectoryTool,
searchFilesTool,
createFileTool,
beginFileWriteTool,
writeFileChunkTool,
finishFileWriteTool,
cancelFileWriteTool,
sendFileAsAttachmentTool,
createDirectoryTool,
copyPathTool,
updateFileTool,
editFilePatchTool,
renamePathTool,
deletePathTool,
] satisfies AiTool[];
// export const notesFileTools: AiTool[] = [
// createNoteTool,
// listNotesTool,
// getNoteContentTool,
// updateNoteContentTool,
// deleteNoteTool,
// sendNoteAsFileTool,
// searchNotesTool
// ]
export const getTools = (forCreator?: boolean) => {
const tools: AiTool[] = [
...defaultTools,
// ...notesFileTools
];
if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(webSearchTool);
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(getWeatherTool);
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(...fileTools);
}
if (forCreator) {
if (Environment.ENABLE_PYTHON_INTERPRETER) {
tools.push(pythonInterpreterTool);
}
if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(shellExecuteTool);
}
}
return tools;
};
export const fileToolHandlers = {
read_file: readFile,
list_directory: listDirectory,
search_files: searchFiles,
create_file: createFile,
begin_file_write: beginFileWrite,
write_file_chunk: writeFileChunk,
finish_file_write: finishFileWrite,
cancel_file_write: cancelFileWrite,
send_file_as_attachment: sendFileAsAttachment,
create_directory: createDirectory,
copy_path: copyPath,
update_file: updateFile,
edit_file_patch: editFilePatch,
rename_path: renamePath,
delete_path: deletePath,
};
export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = {
get_datetime: getCurrentDateTime,
get_financial_market_data: getMarketRates,
// create_note: createNote,
// list_notes: listNotes,
// get_note_content: getNoteContent,
// update_note_content: updateNoteContent,
// delete_note: deleteNote,
// send_note_as_file: sendNoteAsFile,
// search_notes: searchNotes,
...fileToolHandlers,
python_interpreter: runPythonInterpreter,
shell_execute: shellExecute,
web_search: webSearch,
get_weather: getWeather,
};
return handlers;
};
export function getToolPrompts(toolNames: string[]): string[] {
const prompts: string[] = [];
for (const toolName of toolNames) {
if (!prompts.includes(fileToolsToolPrompt) &&
fileTools.map(t => t.function.name).includes(toolName)) {
prompts.push(fileToolsToolPrompt);
continue;
}
switch (toolName) {
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
prompts.push(getFinancialMarketDataToolPrompt);
break;
case WEB_SEARCH_TOOL_NAME:
prompts.push(webSearchToolPrompt);
break;
default:
break;
}
}
return prompts;
}
+61
View File
@@ -0,0 +1,61 @@
import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
};
function stringifyToolResult(result: AiJsonValue): string {
if (typeof result === "string") return result;
return JSON.stringify(result, null, 2);
}
export async function executeToolCall(
userId: number | undefined | null,
name: string,
args?: string | AiJsonObject,
context: ToolRuntimeContext = {},
): Promise<string> {
const startedAt = Date.now();
const handler = getToolHandlers()[name];
logger.info("execute.start", {name, args});
if (!handler) {
return stringifyToolResult({
error: `Unknown tool: ${name}`,
});
}
try {
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
executionTimeoutMs: 8_000,
syntaxTimeoutMs: 3_000,
maxCodeChars: 100_000,
maxOutputChars: 20_000,
inputFiles: context.pythonInputFiles,
});
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
}
const arguments1 = normalizeToolArguments(args, userId);
const result = await handler(arguments1);
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
} catch (error) {
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
return stringifyToolResult({
error: error instanceof Error ? error.message : String(error),
});
}
}
+395
View File
@@ -0,0 +1,395 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
const logger = toolsLogger.child("search-notes");
export type SearchNoteMatchedField = "file_name" | "title" | "content";
export type SearchNoteItem = {
fileName: string;
filePath: string;
relativePath: string;
title: string;
score: number;
matchedFields: SearchNoteMatchedField[];
snippet?: string;
};
export type SearchNotesResult =
| { success: true; results: SearchNoteItem[] }
| { success: false; error: string };
export const searchNotesTool = {
type: "function",
function: {
name: "search_notes",
description:
"Search Markdown notes by file name, note title, and full note content. Supports fuzzy matching. Use this when the user refers to a note by title, topic, partial title, approximate name, keyword, or something written inside the note. Returns success=true and results[], where each result contains fileName, title, score, matchedFields, relativePath, and optional snippet. Later note tools should use results[0].fileName unless multiple results are ambiguous.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query for finding notes by file name, title, topic, keywords, or content. Can be partial, approximate, or contain typos. Use a short clean phrase, not the full user sentence.",
},
limit: {
type: "integer",
description:
"Maximum number of search results to return. Defaults to 3. Maximum is 10.",
minimum: 1,
maximum: 10,
default: 3,
},
},
required: ["query"],
},
},
} satisfies AiTool;
export async function searchNotes(
args?: AiJsonObject,
): Promise<SearchNotesResult> {
const startedAt = Date.now();
logger.debug("start", {args});
const query = asNonEmptyString(args?.query) ?? "";
if (!query.trim().length) {
return {success: false, error: "No query provided"};
}
const limit = parseSearchLimit(args?.limit);
try {
const entries = await readdir(notesDir, {withFileTypes: true});
const markdownFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md"));
const notes = await Promise.all(
markdownFiles.map(async (fileName) => {
const filePath = path.join(notesDir, fileName);
const relativePath = path.relative(path.dirname(notesRootFile), filePath);
let content = "";
try {
content = await readFile(filePath, "utf-8");
} catch {
// Ignore content read errors for individual files.
}
const title = extractNoteTitle(fileName, content);
const fileNameWithoutExtension = path.basename(fileName, ".md");
const fileNameScore = calculateFuzzyScore(query, fileNameWithoutExtension);
const titleScore = calculateFuzzyScore(query, title);
const contentScore = calculateContentScore(query, content);
const matchedFields: SearchNoteMatchedField[] = [];
if (fileNameScore > 0) {
matchedFields.push("file_name");
}
if (titleScore > 0) {
matchedFields.push("title");
}
if (contentScore > 0) {
matchedFields.push("content");
}
const score = Math.max(
fileNameScore,
titleScore,
contentScore,
);
return {
fileName,
filePath,
relativePath,
title,
score,
matchedFields,
snippet:
contentScore > 0
? buildContentSnippet(query, content)
: undefined,
};
}),
);
const results = notes
.filter((note) => note.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)});
return {success: true, results};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to search notes: ${errorMessage}`};
}
}
function parseSearchLimit(value: AiJsonValue | undefined): number {
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: 3;
if (!Number.isFinite(parsed)) {
return 3;
}
return Math.max(1, Math.min(10, Math.floor(parsed)));
}
function extractNoteTitle(fileName: string, content: string): string {
const headingMatch = content.match(/^#\s+(.+)$/m);
const heading = headingMatch?.[1]?.trim();
if (heading) {
return heading;
}
return path.basename(fileName, ".md");
}
function calculateFuzzyScore(query: string, value: string): number {
const normalizedQuery = normalizeSearchText(query);
const normalizedValue = normalizeSearchText(value);
if (!normalizedQuery.length || !normalizedValue.length) {
return 0;
}
if (normalizedValue === normalizedQuery) {
return 100;
}
if (normalizedValue.startsWith(normalizedQuery)) {
return 90;
}
if (normalizedValue.includes(normalizedQuery)) {
return 85;
}
const queryWords = normalizedQuery.split(" ").filter(Boolean);
const valueWords = normalizedValue.split(" ").filter(Boolean);
const wordMatchScore = calculateWordMatchScore(queryWords, valueWords);
const subsequenceScore = isSubsequence(normalizedQuery, normalizedValue) ? 55 : 0;
const distanceScore = calculateLevenshteinScore(normalizedQuery, normalizedValue);
return Math.max(wordMatchScore, subsequenceScore, distanceScore);
}
function calculateContentScore(query: string, content: string): number {
const normalizedQuery = normalizeSearchText(query);
const normalizedContent = normalizeSearchText(content);
if (!normalizedQuery.length || !normalizedContent.length) {
return 0;
}
if (normalizedContent.includes(normalizedQuery)) {
return 70;
}
const queryWords = normalizedQuery.split(" ").filter(Boolean);
const contentWords = new Set(normalizedContent.split(" ").filter(Boolean));
if (!queryWords.length) {
return 0;
}
let matchedWords = 0;
for (const queryWord of queryWords) {
if (contentWords.has(queryWord)) {
matchedWords++;
continue;
}
const hasPartialMatch = [...contentWords].some((contentWord) => {
if (contentWord.includes(queryWord) || queryWord.includes(contentWord)) {
return true;
}
if (queryWord.length < 4 || contentWord.length < 4) {
return false;
}
const distance = levenshteinDistance(queryWord, contentWord);
const maxLength = Math.max(queryWord.length, contentWord.length);
const similarity = 1 - distance / maxLength;
return similarity >= 0.75;
});
if (hasPartialMatch) {
matchedWords += 0.75;
}
}
const matchRatio = matchedWords / queryWords.length;
if (matchRatio <= 0) {
return 0;
}
return Math.round(matchRatio * 60);
}
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.trim()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ё/g, "е")
.replace(/[^a-zа-я0-9\s-]/gi, " ")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ");
}
function calculateWordMatchScore(queryWords: string[], valueWords: string[]): number {
if (!queryWords.length || !valueWords.length) {
return 0;
}
let matchedWords = 0;
for (const queryWord of queryWords) {
const bestWordScore = Math.max(
...valueWords.map((valueWord) => {
if (valueWord === queryWord) {
return 1;
}
if (valueWord.startsWith(queryWord) || valueWord.includes(queryWord)) {
return 0.85;
}
const distance = levenshteinDistance(queryWord, valueWord);
const maxLength = Math.max(queryWord.length, valueWord.length);
const similarity = 1 - distance / maxLength;
return similarity >= 0.7 ? similarity : 0;
}),
);
if (bestWordScore > 0) {
matchedWords += bestWordScore;
}
}
const ratio = matchedWords / queryWords.length;
return Math.round(ratio * 75);
}
function calculateLevenshteinScore(query: string, value: string): number {
const distance = levenshteinDistance(query, value);
const maxLength = Math.max(query.length, value.length);
if (maxLength === 0) {
return 0;
}
const similarity = 1 - distance / maxLength;
if (similarity < 0.45) {
return 0;
}
return Math.round(similarity * 65);
}
function isSubsequence(query: string, value: string): boolean {
let queryIndex = 0;
for (const valueChar of value) {
if (valueChar === query[queryIndex]) {
queryIndex++;
}
if (queryIndex === query.length) {
return true;
}
}
return false;
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = Array.from({length: a.length + 1}, () =>
Array.from({length: b.length + 1}, () => 0),
);
for (let i = 0; i <= a.length; i++) {
matrix[i][0] = i;
}
for (let j = 0; j <= b.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[a.length][b.length];
}
function buildContentSnippet(query: string, content: string): string | undefined {
const normalizedQuery = query.trim().toLowerCase();
const normalizedContent = content.toLowerCase();
let matchIndex = normalizedContent.indexOf(normalizedQuery);
if (matchIndex < 0) {
const queryWords = normalizeSearchText(query)
.split(" ")
.filter((word) => word.length >= 3);
for (const word of queryWords) {
matchIndex = normalizedContent.indexOf(word);
if (matchIndex >= 0) {
break;
}
}
}
if (matchIndex < 0) {
return undefined;
}
const snippetRadius = 120;
const start = Math.max(0, matchIndex - snippetRadius);
const end = Math.min(content.length, matchIndex + normalizedQuery.length + snippetRadius);
const prefix = start > 0 ? "..." : "";
const suffix = end < content.length ? "..." : "";
return `${prefix}${content.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
}
+110
View File
@@ -0,0 +1,110 @@
import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils";
import {asNonEmptyString} from "./utils";
import {AiJsonObject} from "../tool-types";
export const shellExecuteTool = {
type: "function",
function: {
name: "shell_execute",
description: "Execute NON-Python command in a shell. Do not use if you intend to execute some python.",
parameters: {
type: "object",
properties: {
cmd: {
type: "string",
description: "Actual command to execute in a shell"
}
},
required: ["cmd"]
}
}
} satisfies AiTool;
export const shellExecuteToolPrompt = [
"Shell tool rules:",
"- You have access to the `shell_execute` tool.",
"- `shell_execute` executes a shell command on the server.",
"- This tool is powerful and potentially dangerous.",
"- Use this tool only when command execution is actually necessary.",
"- Prefer specialized tools when available, for example filesystem tools for reading, creating, updating, copying, moving or deleting files.",
"",
"Platform awareness:",
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
"- Do not assume Bash/Linux commands are available.",
"- Do not assume Windows commands are available.",
"- If the current OS/shell is unclear, first run a safe environment inspection command.",
"- Safe OS inspection examples:",
" - Node.js: `node -p \"process.platform\"`",
" - Node.js: `node -p \"process.cwd()\"`",
" - Windows CMD: `ver`",
" - PowerShell: `$PSVersionTable.PSVersion`",
" - POSIX shell: `uname -a`",
"",
"Preferred safe commands:",
"- Prefer read-only commands.",
"- Prefer short, explicit and predictable commands.",
"- Cross-platform when Node.js is available:",
" - `node -p \"process.cwd()\"`",
" - `node -p \"process.platform\"`",
" - `node -e \"console.log(require('fs').readdirSync('.'))\"`",
"- POSIX examples:",
" - `pwd`, `ls`, `find`, `cat`, `head`, `tail`, `grep`, `sed -n`, `wc`, `stat`, `file`, `du`, `df`, `ps`.",
"- Windows CMD examples:",
" - `cd`, `dir`, `type`, `where`, `findstr`.",
"- PowerShell examples:",
" - `Get-Location`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Measure-Object`, `Get-Item`, `Get-Process`.",
"",
"Filesystem restrictions:",
"- Work only inside the allowed project/root directory.",
"- Use relative paths when possible.",
"- Do not use absolute paths unless the user explicitly asks and it is safe.",
"- Do not use `..` to go to parent directories.",
"- Do not access files outside the allowed root directory.",
"- Do not follow or use symlinks to escape the allowed root directory.",
"",
"Forbidden actions unless the user explicitly asks and the action is clearly safe:",
"- Do not delete files or directories.",
"- Do not overwrite files.",
"- Do not move files.",
"- Do not change permissions.",
"- Do not change ownership.",
"- Do not install packages.",
"- Do not update the system.",
"- Do not start, stop or restart services.",
"- Do not run background processes.",
"- Do not run long-running commands.",
"- Do not run infinite loops.",
"- Do not use fork bombs.",
"- Do not use privilege escalation.",
"",
"Forbidden command examples:",
"- POSIX: `sudo`, `su`, `rm`, `rmdir`, `chmod`, `chown`, `dd`, `mkfs`, `mount`, `umount`, `kill`, `reboot`, `shutdown`.",
"- Windows CMD: `del`, `erase`, `rmdir`, `rd`, `format`, `shutdown`, `taskkill`.",
"- PowerShell: `Remove-Item`, `Move-Item`, `Set-ItemProperty`, `Stop-Process`, `Restart-Computer`, `Stop-Computer`.",
"",
"Network restrictions:",
"- Do not make network requests unless the user explicitly asks.",
"- Do not use `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod`, `ssh`, `scp`, `rsync`, `nc`, `nmap` unless explicitly requested and safe.",
"",
"Secrets and privacy:",
"- Never read secrets, tokens, API keys, passwords, private keys, certificates, `.env` files, SSH keys, browser data or credential stores unless the user explicitly asks and it is necessary.",
"- If command output contains secrets, do not repeat them back to the user.",
"",
"Command construction:",
"- Do not execute untrusted user text directly as shell code.",
"- Quote paths and arguments safely.",
"- Avoid command chaining with `;`, `&&`, `||`, pipes, backticks or command substitution unless necessary.",
"- Avoid glob patterns that may affect too many files.",
"- If unsure whether a command is safe, do not run it.",
"",
].join("\n");
export async function shellExecute(args?: AiJsonObject): Promise<string | undefined | null> {
const cmd = asNonEmptyString(args?.cmd);
if (!cmd) return undefined;
const {stdout, stderr} = await runCommand(cmd);
return stdout ?? stderr;
}
+3
View File
@@ -0,0 +1,3 @@
import {appLogger} from "../../logging/logger";
export const toolsLogger = appLogger.child("ai-tools");
+3
View File
@@ -0,0 +1,3 @@
import {AiJsonObject, AiJsonValue} from "../tool-types";
export type ToolHandler = (args?: AiJsonObject) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
+113
View File
@@ -0,0 +1,113 @@
import {Ollama} from "ollama";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import type {BoundaryValue} from "../../common/boundary-types";
const logger = toolsLogger.child("utils");
export function asNonEmptyString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject {
if (!args) return {};
if (typeof args === "string") {
try {
const parsed = JSON.parse(args) as AiJsonValue;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as AiJsonObject;
}
} catch {
return {
raw: args,
};
}
return {};
}
if (typeof args === "object" && !Array.isArray(args)) {
const userIdObject = userId ? {"userId": userId} : {};
return {
...args,
...userIdObject,
} as AiJsonObject;
}
return {};
}
export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
}
return defaultValue;
}
export function asString(value: BoundaryValue, defaultValue = ""): string {
return typeof value === "string" ? value : defaultValue;
}
export function asPositiveInt(value: BoundaryValue, defaultValue: number, maxValue: number): number {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return defaultValue;
return Math.min(Math.floor(n), maxValue);
}
export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]) {
try {
const runningModels = await ollama.ps();
const modelsToUnload = runningModels.models
.filter(m => !exceptFor?.includes(m.model));
const unloadPromises = modelsToUnload
.map(model =>
ollama.generate({
model: model.name,
keep_alive: 0,
stream: false,
prompt: ""
})
);
await Promise.all(unloadPromises);
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
} catch (error) {
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)});
}
}
export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> {
try {
logger.info("ollama.load.start", {model, contextLength});
await ollama.generate({
model: model,
stream: false,
prompt: "",
options: {
num_ctx: contextLength
}
});
logger.info("ollama.load.done", {model, contextLength});
return true;
} catch (error) {
logger.error("ollama.load.failed", {model, contextLength, error: error instanceof Error ? error : String(error)});
return false;
}
}
+151
View File
@@ -0,0 +1,151 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
export const getWeatherTool = {
type: "function",
function: {
name: "get_weather",
description: "Get the current temperature for a city.",
parameters: {
type: "object",
properties: {
city: {
type: "string",
description: "The name of the city."
},
lang: {
type: "string",
description: "language code for the response/content. Must be a valid ISO 639-1 two-letter language code, for example: \"en\", \"ru\", \"de\", \"fr\".Determine the value automatically from the language the user is using to communicate with the LLM. If the user explicitly requests a specific language, use that requested language instead. Do not use language names, locales, or regional variants such as \"English\", \"ru-RU\", or \"en_US\"; return only the ISO 639-1 code."
}
},
required: ["city", "lang"],
}
}
} satisfies AiTool;
export const weatherToolPrompt = [
"Weather tool rules:",
"- Use `get_weather` for current weather, current temperature, conditions, hot/cold/rainy/snowy questions, and weather follow-ups.",
"- Weather is live/current data. Never answer it from memory.",
"- A weather tool result is valid only for the exact city used in that tool call.",
"- If the user changes the city, call `get_weather` again.",
"- Follow-up questions like `what about Moscow?`, `and for Krasnodar?`, `what about there?`, `what about Berlin?` inherit the previous weather intent and require a new tool call for the new city.",
"",
"Arguments:",
"- `city`: the city from the latest user request or resolved from the follow-up context.",
"- `lang`: ISO 639-1 two-letter language code only: `ru`, `en`, `de`, etc.",
"",
"Do not guess, compare, or reuse weather from another city.",
"If the city is missing or unclear, ask the user to specify it.",
].join("\n");
export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | null> {
const startedAt = Date.now();
logger.info("start", {args});
try {
const city = asNonEmptyString(args?.city);
const lang = asNonEmptyString(args?.lang);
if (!city) {
return null;
}
const apiKey = Environment.OPEN_WEATHER_MAP_API_KEY;
const geocodeResponse = (await axios.get("https://api.openweathermap.org/geo/1.0/direct", {
params: {
q: city,
limit: 1,
appid: apiKey,
},
})).data[0];
logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
if (!geocodeResponse) {
return {
ok: false,
tool: "get_weather",
error: `City not found: ${city}`,
city,
lang,
};
}
const lat = geocodeResponse.lat;
const lon = geocodeResponse.lon;
const response = (await axios.get("https://api.openweathermap.org/data/2.5/weather", {
params: {
lat,
lon,
units: "metric",
appid: apiKey,
...(lang ? {lang} : {}),
},
})).data;
logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!response});
const main = response.main;
const sys = response.sys;
const wind = response.wind;
const weather = response.weather[0];
let date = new Date(sys.sunrise * 1000);
const sunrise = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
date = new Date(sys.sunset * 1000);
const sunset = [
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
]
.map((v) => String(v).padStart(2, "0"))
.join(":");
return {
ok: true,
tool: "get_weather",
scope: {
city,
lang,
validOnlyForExactCity: true,
liveData: true,
note: "If the user asks about another city, call get_weather again.",
},
weather: {
main: weather.main,
description: weather.description,
temperature: main.temp,
temperatureMax: main.temp_max,
temperatureMin: main.temp_min,
feelsLike: main.feels_like,
humidity: main.humidity,
pressure: main.pressure,
seaLevel: main.sea_level ?? null,
groundLevel: main.grnd_level ?? null,
sunriseUtc: sunrise,
sunsetUtc: sunset,
windDegree: wind.deg,
windSpeed: wind.speed,
},
};
} catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)});
logError(error instanceof Error ? error : String(error));
return null;
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
+403
View File
@@ -0,0 +1,403 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {asBoolean, asNonEmptyString} from "./utils";
type BraveSearchProfile = {
name?: string;
long_name?: string;
url?: string;
img?: string;
};
type BraveSearchMetaUrl = {
scheme?: string;
netloc?: string;
hostname?: string;
favicon?: string;
path?: string;
};
type BraveSearchThumbnail = {
src?: string;
original?: string;
};
type BraveSearchResult = {
type?: string;
title?: string;
url?: string;
description?: string;
age?: string;
page_age?: string;
language?: string;
family_friendly?: boolean;
is_source_local?: boolean;
is_source_both?: boolean;
profile?: BraveSearchProfile;
meta_url?: BraveSearchMetaUrl;
thumbnail?: BraveSearchThumbnail;
extra_snippets?: string[];
};
type BraveSearchApiResponse = {
type?: string;
query?: {
original?: string;
show_strict_warning?: boolean;
is_navigational?: boolean;
is_news_breaking?: boolean;
spellcheck_off?: boolean;
country?: string;
bad_results?: boolean;
should_fallback?: boolean;
postal_code?: string;
city?: string;
header_country?: string;
more_results_available?: boolean;
state?: string;
altered?: string;
};
web?: {
type?: string;
results?: BraveSearchResult[];
};
news?: {
type?: string;
results?: BraveSearchResult[];
};
videos?: {
type?: string;
results?: BraveSearchResult[];
};
discussions?: {
type?: string;
results?: BraveSearchResult[];
};
faq?: AiJsonValue;
infobox?: AiJsonValue;
locations?: AiJsonValue;
mixed?: AiJsonValue;
summarizer?: AiJsonValue;
};
export const WEB_SEARCH_TOOL_NAME = "web_search";
export const webSearchTool = {
type: "function",
function: {
name: WEB_SEARCH_TOOL_NAME,
description:
"Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query. Must be non-empty. Maximum 400 characters and 50 words.",
},
count: {
type: "number",
description:
"Number of web results to return. Min 1, max 20. Default is 5.",
},
offset: {
type: "number",
description:
"Zero-based page offset. Min 0, max 9. Default is 0.",
},
country: {
type: "string",
description:
"Optional 2-letter country code for result localization, for example US, RU, DE. Default is US.",
},
searchLang: {
type: "string",
description:
"Optional search language code, for example en, ru, de. Default is en.",
},
uiLang: {
type: "string",
description:
"Optional UI language, usually language-country format, for example en-US, ru-RU, de-DE.",
},
safesearch: {
type: "string",
enum: ["off", "moderate", "strict"],
description:
"Adult content filter. Default is moderate.",
},
freshness: {
type: "string",
description:
"Optional freshness filter: pd for last 24h, pw for last 7 days, pm for last 31 days, py for last 365 days, or YYYY-MM-DDtoYYYY-MM-DD.",
},
resultFilter: {
type: "string",
description:
"Comma-separated result types. Examples: web, news, videos, discussions, faq, infobox, locations, query, summarizer. Default is web.",
},
extraSnippets: {
type: "boolean",
description:
"Whether to request extra snippets. Default is false.",
},
spellcheck: {
type: "boolean",
description:
"Whether Brave may spellcheck and alter the query. Default is true.",
},
},
required: ["query"],
},
},
} satisfies AiTool;
export const webSearchToolPrompt = [
"Brave Search tool rules:",
"- You have access to `web_search`.",
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
"- Use `web_search` for normal web search results.",
"- Do not use `shell_execute` for web search.",
"",
"How to query:",
"- Keep search queries short and focused.",
"- Prefer the user's original language unless another language is clearly better for the topic.",
"- Use `searchLang` based on the expected language of results: `ru` for Russian, `en` for English, `de` for German.",
"- Use `country` for localization when relevant, for example `RU`, `US`, `DE`.",
"- Use `count` between 3 and 10 by default.",
"- Use `resultFilter: \"web\"` for normal search.",
"- Use `resultFilter: \"news,web\"` for recent news/events.",
"- Use `resultFilter: \"videos\"` only when the user asks for videos.",
"- Use `resultFilter: \"discussions,web\"` when forum/community opinions are useful.",
"",
"Freshness:",
"- Use `freshness: \"pd\"` for last 24 hours.",
"- Use `freshness: \"pw\"` for last 7 days.",
"- Use `freshness: \"pm\"` for last 31 days.",
"- Use `freshness: \"py\"` for last 365 days.",
"- Use a custom range like `2025-01-01to2025-12-31` only when the user asks for a specific date range.",
"",
"Answering:",
"- Treat snippets as hints, not as full source documents.",
"- Do not invent details that are not present in the search results.",
"- When giving factual claims based on search results, mention the source title or URL.",
"- If results are weak, ambiguous or empty, say that the search result was insufficient.",
"",
].join("\n");
function asIntegerInRange(
value: AiJsonValue | undefined,
fallback: number,
min: number,
max: number,
): number {
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(parsed)) return fallback;
const int = Math.trunc(parsed);
return Math.min(max, Math.max(min, int));
}
function asEnum<T extends string>(
value: AiJsonValue | undefined,
allowed: readonly T[],
fallback: T,
): T {
if (typeof value !== "string") return fallback;
const normalized = value.trim();
return allowed.includes(normalized as T)
? normalized as T
: fallback;
}
function cleanSearchText(value: AiJsonValue | undefined): string | null {
if (typeof value !== "string") return null;
return value
.replace(/<[^>]*>/g, "")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim() || null;
}
function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
const allowed = new Set([
"discussions",
"faq",
"infobox",
"news",
"query",
"summarizer",
"videos",
"web",
"locations",
]);
const raw = asNonEmptyString(value);
if (!raw) return "web";
const parts = raw
.split(",")
.map(part => part.trim().toLowerCase())
.filter(part => allowed.has(part));
return parts.length ? [...new Set(parts)].join(",") : "web";
}
export async function webSearch(args?: AiJsonObject) {
const startedAt = Date.now();
logger.info("start", {args});
try {
const query = asNonEmptyString(args?.query);
if (!query) {
throw new Error("query is required");
}
if (query.length > 400) {
throw new Error("query is too long. Max allowed length is 400 characters.");
}
const wordCount = query.split(/\s+/).filter(Boolean).length;
if (wordCount > 50) {
throw new Error("query has too many words. Max allowed word count is 50.");
}
const count = asIntegerInRange(args?.count, 5, 1, 20);
const offset = asIntegerInRange(args?.offset, 0, 0, 9);
const country = asNonEmptyString(args?.country)?.toUpperCase() ?? "US";
const searchLang = asNonEmptyString(args?.searchLang)?.toLowerCase() ?? "en";
const uiLang = asNonEmptyString(args?.uiLang) ?? undefined;
const safesearch = asEnum(
args?.safesearch,
["off", "moderate", "strict"] as const,
"moderate",
);
const freshness = asNonEmptyString(args?.freshness);
const resultFilter = normalizeBraveResultFilter(args?.resultFilter);
const extraSnippets = asBoolean(args?.extraSnippets, false);
const spellcheck = asBoolean(args?.spellcheck, true);
const response = await axios.get<BraveSearchApiResponse>(
"https://api.search.brave.com/res/v1/web/search",
{
timeout: 10_000,
params: {
q: query,
count,
offset,
country,
search_lang: searchLang,
safesearch,
result_filter: resultFilter,
text_decorations: false,
spellcheck,
extra_snippets: extraSnippets,
...(uiLang ? {ui_lang: uiLang} : {}),
...(freshness ? {freshness} : {}),
},
headers: {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": Environment.BRAVE_SEARCH_API_KEY,
"User-Agent": "TelegramBot/1.0",
},
},
);
const data = response.data;
return {
ok: true,
query,
alteredQuery: data.query?.altered ?? null,
moreResultsAvailable: data.query?.more_results_available ?? null,
resultFilter,
count,
offset,
country,
searchLang,
safesearch,
freshness: freshness ?? null,
web: data.web?.results?.map(mapBraveResult) ?? [],
news: data.news?.results?.map(mapBraveResult) ?? [],
videos: data.videos?.results?.map(mapBraveResult) ?? [],
discussions: data.discussions?.results?.map(mapBraveResult) ?? [],
hasInfobox: Boolean(data.infobox),
hasFaq: Boolean(data.faq),
hasLocations: Boolean(data.locations),
hasSummarizer: Boolean(data.summarizer),
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
};
} catch (error) {
logError(error instanceof Error ? error : String(error));
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
const data = axios.isAxiosError(error) ? error.response?.data : undefined;
return {
ok: false,
status: typeof status === "number" ? status : null,
error: error instanceof Error ? error.message : String(error),
response: data ?? null,
};
} finally {
logger.debug("done", {duration: logger.duration(startedAt)});
}
}
function mapBraveResult(result: BraveSearchResult) {
return {
title: cleanSearchText(result.title),
url: asNonEmptyString(result.url) ?? null,
description: cleanSearchText(result.description),
age: asNonEmptyString(result.age) ?? asNonEmptyString(result.page_age) ?? null,
language: asNonEmptyString(result.language) ?? null,
source: asNonEmptyString(result.profile?.name)
?? asNonEmptyString(result.profile?.long_name)
?? asNonEmptyString(result.meta_url?.hostname)
?? null,
hostname: asNonEmptyString(result.meta_url?.hostname) ?? null,
thumbnail: asNonEmptyString(result.thumbnail?.src)
?? asNonEmptyString(result.thumbnail?.original)
?? null,
extraSnippets: Array.isArray(result.extra_snippets)
? result.extra_snippets
.map(cleanSearchText)
.filter((value): value is string => Boolean(value))
: [],
};
}