ai: add common tool runtime and built-in tools
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import axios from "axios";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {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?: unknown;
|
||||
infobox?: unknown;
|
||||
locations?: unknown;
|
||||
mixed?: unknown;
|
||||
summarizer?: unknown;
|
||||
};
|
||||
|
||||
export const braveSearchTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "web_search",
|
||||
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 braveSearchToolPrompt = [
|
||||
"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: unknown,
|
||||
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: unknown,
|
||||
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: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
return value
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() || null;
|
||||
}
|
||||
|
||||
function normalizeBraveResultFilter(value: unknown): 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?: Record<string, unknown>) {
|
||||
console.log("braveSearch()");
|
||||
|
||||
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 (e: any) {
|
||||
logError(e);
|
||||
|
||||
const status = e?.response?.status;
|
||||
const data = e?.response?.data;
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: typeof status === "number" ? status : null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
response: data ?? null,
|
||||
};
|
||||
} finally {
|
||||
console.log("END: braveSearch()");
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
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?: Record<string, unknown>) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
||||
|
||||
export const readFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory, for example notes/task.txt.",
|
||||
},
|
||||
maxBytes: {
|
||||
type: "number",
|
||||
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const listDirectoryTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_directory",
|
||||
description:
|
||||
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative directory path inside the root directory. Use . for root.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const createFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_file",
|
||||
description:
|
||||
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "File content.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite the file if it already exists. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const createDirectoryTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_directory",
|
||||
description:
|
||||
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Whether to create parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const copyPathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "copy_path",
|
||||
description:
|
||||
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourcePath: {
|
||||
type: "string",
|
||||
description: "Relative source file or directory path inside the root directory.",
|
||||
},
|
||||
targetPath: {
|
||||
type: "string",
|
||||
description: "Relative target file or directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Required for copying directories. Default is false.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create target parent directories automatically. Default is true.",
|
||||
},
|
||||
},
|
||||
required: ["sourcePath", "targetPath"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const updateFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_file",
|
||||
description:
|
||||
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file path inside the root directory.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Content to write.",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["replace", "append", "prepend"],
|
||||
description: "Update mode. Default is replace.",
|
||||
},
|
||||
createIfMissing: {
|
||||
type: "boolean",
|
||||
description: "Whether to create the file if it does not exist. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const renamePathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "rename_path",
|
||||
description:
|
||||
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourcePath: {
|
||||
type: "string",
|
||||
description: "Relative source path inside the root directory.",
|
||||
},
|
||||
targetPath: {
|
||||
type: "string",
|
||||
description: "Relative target path inside the root directory.",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "Whether to create target parent directories automatically. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["sourcePath", "targetPath"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const deletePathTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_path",
|
||||
description:
|
||||
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative file or directory path inside the root directory.",
|
||||
},
|
||||
recursive: {
|
||||
type: "boolean",
|
||||
description: "Whether to delete non-empty directories recursively. Default is false.",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const fileToolsToolPrompt = [
|
||||
"Filesystem tool rules:",
|
||||
"- You have access to filesystem tools working only inside the hardcoded root directory.",
|
||||
"- All filesystem paths must be relative to the root directory.",
|
||||
"- You may go into child directories.",
|
||||
"- You must never go up to parent directories.",
|
||||
"- Do not use ../ paths.",
|
||||
"- Do not use absolute paths.",
|
||||
"- Do not try to access symlinks.",
|
||||
"- Use read_file for reading files.",
|
||||
"- Use list_directory for reading directories.",
|
||||
"- Use create_file for creating files.",
|
||||
"- Use create_directory for creating directories.",
|
||||
"- Use update_file for replacing, appending or prepending file content.",
|
||||
"- Use rename_path for renaming or moving files/directories inside the root.",
|
||||
"- Use delete_path for deleting files/directories inside the root.",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
|
||||
|
||||
async function ensureFileToolsRootExists(): Promise<void> {
|
||||
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
|
||||
|
||||
const stat = await fs.promises.stat(requireFileToolsRootDir());
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
} {
|
||||
const rawPath = asNonEmptyString(inputPath) ?? fallback;
|
||||
|
||||
if (rawPath.includes("\0")) {
|
||||
throw new Error("Path must not contain null bytes.");
|
||||
}
|
||||
|
||||
if (
|
||||
path.isAbsolute(rawPath) ||
|
||||
path.win32.isAbsolute(rawPath) ||
|
||||
path.posix.isAbsolute(rawPath)
|
||||
) {
|
||||
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
|
||||
}
|
||||
|
||||
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
|
||||
|
||||
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
|
||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
||||
|
||||
if (
|
||||
relativePath.startsWith("..") ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error("Path escapes the root directory. Going up is not allowed.");
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: relativePath || ".",
|
||||
};
|
||||
}
|
||||
|
||||
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
|
||||
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
|
||||
|
||||
if (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
) {
|
||||
throw new Error("Cannot copy a directory into itself.");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoSymlinkInPath(
|
||||
absolutePath: string,
|
||||
options?: {
|
||||
allowMissingTail?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
await ensureFileToolsRootExists();
|
||||
|
||||
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
|
||||
|
||||
if (!relativePath || relativePath === ".") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
||||
|
||||
let currentPath = requireFileToolsRootDir();
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath = path.join(currentPath, part);
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.lstat(currentPath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in file tool paths.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT" && options?.allowMissingTail) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.lstat(absolutePath);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotRoot(relativePath: string): void {
|
||||
if (relativePath === ".") {
|
||||
throw new Error("Operation on the root directory itself is not allowed.");
|
||||
}
|
||||
}
|
||||
|
||||
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
|
||||
if (stat.isSymbolicLink()) return "symlink";
|
||||
if (stat.isFile()) return "file";
|
||||
if (stat.isDirectory()) return "directory";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export async function readFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Path is not a file: ${relativePath}`);
|
||||
}
|
||||
|
||||
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
|
||||
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
|
||||
}
|
||||
|
||||
const buffer = await fs.promises.readFile(absolutePath);
|
||||
|
||||
if (buffer.includes(0)) {
|
||||
throw new Error("Binary files are not supported.");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
sizeBytes: stat.size,
|
||||
content: buffer.toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${relativePath}`);
|
||||
}
|
||||
|
||||
const dirEntries = await fs.promises.readdir(absolutePath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
|
||||
const entries = await Promise.all(limitedEntries.map(async entry => {
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryRelativePath = relativePath === "."
|
||||
? entry.name
|
||||
: path.join(relativePath, entry.name);
|
||||
|
||||
const entryStat = await fs.promises.lstat(entryAbsolutePath);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryRelativePath,
|
||||
type: getEntryType(entryStat),
|
||||
sizeBytes: entryStat.isFile() ? entryStat.size : null,
|
||||
modifiedAt: entryStat.mtime.toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
entries,
|
||||
totalEntries: dirEntries.length,
|
||||
returnedEntries: entries.length,
|
||||
truncated: dirEntries.length > entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
const content = asString(args?.content, "");
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, true);
|
||||
|
||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
||||
|
||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
|
||||
await fs.promises.mkdir(parentPath, {recursive: true});
|
||||
} else {
|
||||
await assertNoSymlinkInPath(parentPath);
|
||||
}
|
||||
|
||||
if (await pathExists(absolutePath)) {
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`Path is a directory, not a file: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`File already exists: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
flag: overwrite ? "w" : "wx",
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
sizeBytes: contentSizeBytes,
|
||||
overwritten: overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
type CopyPathStats = {
|
||||
entries: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
async function copyPathRecursive(params: {
|
||||
sourceAbsolutePath: string;
|
||||
targetAbsolutePath: string;
|
||||
overwrite: boolean;
|
||||
stats: CopyPathStats;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
sourceAbsolutePath,
|
||||
targetAbsolutePath,
|
||||
overwrite,
|
||||
stats,
|
||||
} = params;
|
||||
|
||||
if (stats.entries >= MAX_COPY_ENTRIES) {
|
||||
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
|
||||
}
|
||||
|
||||
stats.entries++;
|
||||
|
||||
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlinks are not allowed in copied paths.");
|
||||
}
|
||||
|
||||
if (sourceStat.isFile()) {
|
||||
stats.totalBytes += sourceStat.size;
|
||||
|
||||
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
|
||||
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
if (await pathExists(targetAbsolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (targetStat.isDirectory()) {
|
||||
throw new Error("Cannot overwrite a directory with a file.");
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.copyFile(
|
||||
sourceAbsolutePath,
|
||||
targetAbsolutePath,
|
||||
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
if (await pathExists(targetAbsolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(targetAbsolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (!targetStat.isDirectory()) {
|
||||
throw new Error("Cannot overwrite a file with a directory.");
|
||||
}
|
||||
} else {
|
||||
await fs.promises.mkdir(targetAbsolutePath);
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(sourceAbsolutePath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const childSourcePath = path.join(sourceAbsolutePath, entry);
|
||||
const childTargetPath = path.join(targetAbsolutePath, entry);
|
||||
|
||||
await copyPathRecursive({
|
||||
sourceAbsolutePath: childSourcePath,
|
||||
targetAbsolutePath: childTargetPath,
|
||||
overwrite,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Only files and directories can be copied.");
|
||||
}
|
||||
|
||||
export async function copyPath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath);
|
||||
const target = resolveSafeToolPath(args?.targetPath);
|
||||
|
||||
assertNotRoot(source.relativePath);
|
||||
assertNotRoot(target.relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(source.absolutePath);
|
||||
|
||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink sources are not allowed.");
|
||||
}
|
||||
|
||||
const recursive = asBoolean(args?.recursive, false);
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, true);
|
||||
|
||||
if (sourceStat.isDirectory() && !recursive) {
|
||||
throw new Error("Source is a directory. Set recursive=true to copy directories.");
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
|
||||
}
|
||||
|
||||
const targetParentPath = path.dirname(target.absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(targetParentPath, {
|
||||
allowMissingTail: true,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(targetParentPath, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
} else {
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
}
|
||||
|
||||
const stats: CopyPathStats = {
|
||||
entries: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
await copyPathRecursive({
|
||||
sourceAbsolutePath: source.absolutePath,
|
||||
targetAbsolutePath: target.absolutePath,
|
||||
overwrite,
|
||||
stats,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
from: source.relativePath,
|
||||
to: target.relativePath,
|
||||
recursive,
|
||||
overwrite,
|
||||
entriesCopied: stats.entries,
|
||||
bytesCopied: stats.totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDirectory(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
const recursive = asBoolean(args?.recursive, true);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath, {
|
||||
allowMissingTail: true,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(absolutePath, {
|
||||
recursive,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
recursive,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFile(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
const content = asString(args?.content, "");
|
||||
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
|
||||
const createIfMissing = asBoolean(args?.createIfMissing, false);
|
||||
|
||||
if (!["replace", "append", "prepend"].includes(mode)) {
|
||||
throw new Error(`Unsupported update mode: ${mode}`);
|
||||
}
|
||||
|
||||
const contentSizeBytes = Buffer.byteLength(content, "utf8");
|
||||
|
||||
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(absolutePath);
|
||||
|
||||
await assertNoSymlinkInPath(parentPath);
|
||||
|
||||
const exists = await pathExists(absolutePath);
|
||||
|
||||
if (!exists && !createIfMissing) {
|
||||
throw new Error(`File does not exist: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Path is not a file: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "replace") {
|
||||
await fs.promises.writeFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
flag: "w",
|
||||
});
|
||||
} else if (mode === "append") {
|
||||
await fs.promises.appendFile(absolutePath, content, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} else {
|
||||
const oldContent = exists
|
||||
? await fs.promises.readFile(absolutePath, "utf8")
|
||||
: "";
|
||||
|
||||
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
|
||||
|
||||
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
|
||||
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(absolutePath, content + oldContent, {
|
||||
encoding: "utf8",
|
||||
flag: "w",
|
||||
});
|
||||
}
|
||||
|
||||
const newStat = await fs.promises.stat(absolutePath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
mode,
|
||||
sizeBytes: newStat.size,
|
||||
created: !exists,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renamePath(args?: Record<string, unknown>) {
|
||||
const source = resolveSafeToolPath(args?.sourcePath);
|
||||
const target = resolveSafeToolPath(args?.targetPath);
|
||||
|
||||
assertNotRoot(source.relativePath);
|
||||
assertNotRoot(target.relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(source.absolutePath);
|
||||
|
||||
const sourceStat = await fs.promises.lstat(source.absolutePath);
|
||||
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
|
||||
|
||||
if (
|
||||
relativeTargetInsideSource === "" ||
|
||||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
|
||||
) {
|
||||
throw new Error("Cannot move a directory into itself.");
|
||||
}
|
||||
|
||||
const overwrite = asBoolean(args?.overwrite, false);
|
||||
const createParents = asBoolean(args?.createParents, false);
|
||||
|
||||
const targetParentPath = path.dirname(target.absolutePath);
|
||||
|
||||
if (createParents) {
|
||||
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
|
||||
await fs.promises.mkdir(targetParentPath, {recursive: true});
|
||||
} else {
|
||||
await assertNoSymlinkInPath(targetParentPath);
|
||||
}
|
||||
|
||||
if (await pathExists(target.absolutePath)) {
|
||||
const targetStat = await fs.promises.lstat(target.absolutePath);
|
||||
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
throw new Error(`Target already exists: ${target.relativePath}`);
|
||||
}
|
||||
|
||||
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
|
||||
throw new Error("Overwrite for directories is not supported.");
|
||||
}
|
||||
|
||||
await fs.promises.rm(target.absolutePath, {
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
|
||||
await fs.promises.rename(source.absolutePath, target.absolutePath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
from: source.relativePath,
|
||||
to: target.relativePath,
|
||||
overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePath(args?: Record<string, unknown>) {
|
||||
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
|
||||
|
||||
assertNotRoot(relativePath);
|
||||
|
||||
await assertNoSymlinkInPath(absolutePath);
|
||||
|
||||
const stat = await fs.promises.lstat(absolutePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error("Symlink targets are not allowed.");
|
||||
}
|
||||
|
||||
const recursive = asBoolean(args?.recursive, false);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (recursive) {
|
||||
await fs.promises.rm(absolutePath, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
});
|
||||
} else {
|
||||
await fs.promises.rmdir(absolutePath);
|
||||
}
|
||||
} else {
|
||||
await fs.promises.rm(absolutePath, {
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
path: relativePath,
|
||||
recursive,
|
||||
deleted: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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;
|
||||
@@ -0,0 +1,69 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import axios from "axios";
|
||||
|
||||
export const getMarketRatesTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_market_rates",
|
||||
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 marketRatesToolPrompt = [
|
||||
"Currency rates tool rules:",
|
||||
"- Use `get_market_rates` whenever the answer depends on current exchange rates, crypto prices, or gold price.",
|
||||
"- Use `get_market_rates` when the user asks whether a supported asset went up or down recently.",
|
||||
"- Use `get_market_rates` 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<any | undefined> {
|
||||
try {
|
||||
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
|
||||
return response.data;
|
||||
} catch (e: any) {
|
||||
console.error("GET_MARKET_RATES", e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
import {spawn} from "node:child_process";
|
||||
import {copyFile, lstat, mkdir, readdir, 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 {randomUUID} from "node:crypto";
|
||||
|
||||
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: unknown,
|
||||
options: PythonInterpreterOptions = {},
|
||||
): Promise<PythonToolResult> {
|
||||
let args: PythonInterpreterArgs;
|
||||
|
||||
try {
|
||||
args = parsePythonInterpreterArgs(rawArgs, options);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(error),
|
||||
};
|
||||
}
|
||||
|
||||
console.time("python.syntax");
|
||||
const syntax = await validatePythonSyntax(args.code, options);
|
||||
console.timeEnd("python.syntax");
|
||||
|
||||
if (!syntax.ok) {
|
||||
return syntax;
|
||||
}
|
||||
|
||||
console.time("python.execution");
|
||||
const result = await executePythonCode(args, options);
|
||||
console.timeEnd("python.execution");
|
||||
|
||||
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> {
|
||||
console.log("EXECUTE_PYTHON_CODE", "ARGS: ", JSON.stringify(args), "; OPTIONS: ", JSON.stringify(options));
|
||||
|
||||
const pythonBinary =
|
||||
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe";
|
||||
|
||||
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 tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-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,
|
||||
});
|
||||
|
||||
console.log("EXECUTE_PYTHON_CODE", "SCRIPT FILE WRITTEN", new Date());
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ACHIEVED", new Date());
|
||||
|
||||
if (result.timedOut) {
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TIMED OUT", new Date());
|
||||
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) {
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TRUNCATED", new Date());
|
||||
|
||||
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) {
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR EXIT CODE", new Date(), "\n", JSON.stringify(result, null, 2));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT NORMAL", new Date());
|
||||
|
||||
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) {
|
||||
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR", new Date());
|
||||
return {
|
||||
ok: false,
|
||||
phase: "internal",
|
||||
error: errorToString(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: unknown,
|
||||
options: PythonInterpreterOptions,
|
||||
): PythonInterpreterArgs {
|
||||
let args = rawArgs;
|
||||
|
||||
if (typeof rawArgs === "string") {
|
||||
try {
|
||||
args = JSON.parse(rawArgs);
|
||||
} catch {
|
||||
args = {code: rawArgs};
|
||||
}
|
||||
}
|
||||
|
||||
if (!args || typeof args !== "object") {
|
||||
throw new Error("Tool arguments must be an object.");
|
||||
}
|
||||
|
||||
const record = args as Record<string, unknown>;
|
||||
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,
|
||||
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: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.stack || error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {braveSearchTool, webSearch} from "./brave-search";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
||||
import {shellExecute, shellExecuteTool} from "./shell";
|
||||
import {ToolHandler} from "./types";
|
||||
import {getWeather, getWeatherTool} from "./weather";
|
||||
import {getMarketRates, getMarketRatesTool} from "./market-rates";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
||||
import {
|
||||
copyPath,
|
||||
copyPathTool,
|
||||
createDirectory,
|
||||
createDirectoryTool,
|
||||
createFile,
|
||||
createFileTool,
|
||||
deletePath,
|
||||
deletePathTool,
|
||||
listDirectory,
|
||||
listDirectoryTool,
|
||||
readFile,
|
||||
readFileTool,
|
||||
renamePath,
|
||||
renamePathTool,
|
||||
updateFile,
|
||||
updateFileTool
|
||||
} from "./file-system";
|
||||
|
||||
export const getTools = () => {
|
||||
const tools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getMarketRatesTool,
|
||||
];
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
tools.push(pythonInterpreterTool);
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
tools.push(shellExecuteTool);
|
||||
}
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(braveSearchTool);
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
tools.push(getWeatherTool);
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(
|
||||
readFileTool,
|
||||
listDirectoryTool,
|
||||
createFileTool,
|
||||
createDirectoryTool,
|
||||
updateFileTool,
|
||||
renamePathTool,
|
||||
copyPathTool,
|
||||
deletePathTool,
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
get_market_rates: getMarketRates,
|
||||
};
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
handlers = {
|
||||
python_interpreter: runPythonInterpreter,
|
||||
...handlers
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
handlers = {
|
||||
shell_execute: shellExecute,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
handlers = {
|
||||
web_search: webSearch,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
handlers = {
|
||||
get_weather: getWeather,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
handlers = {
|
||||
read_file: readFile,
|
||||
list_directory: listDirectory,
|
||||
create_file: createFile,
|
||||
create_directory: createDirectory,
|
||||
update_file: updateFile,
|
||||
rename_path: renamePath,
|
||||
copy_path: copyPath,
|
||||
delete_path: deletePath,
|
||||
...handlers,
|
||||
};
|
||||
}
|
||||
|
||||
return handlers;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import {getToolHandlers} from "./registry";
|
||||
import {normalizeToolArguments} from "./utils";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
||||
|
||||
export type ToolRuntimeContext = {
|
||||
pythonInputFiles?: PythonInterpreterInputFile[];
|
||||
};
|
||||
|
||||
function stringifyToolResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
export async function executeToolCall(
|
||||
name: string,
|
||||
args?: unknown,
|
||||
context: ToolRuntimeContext = {},
|
||||
): Promise<string> {
|
||||
const handler = getToolHandlers()[name];
|
||||
|
||||
if (!handler) {
|
||||
return stringifyToolResult({
|
||||
error: `Unknown tool: ${name}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
|
||||
const result = await runPythonInterpreter(normalizeToolArguments(args), {
|
||||
executionTimeoutMs: 8_000,
|
||||
syntaxTimeoutMs: 3_000,
|
||||
maxCodeChars: 100_000,
|
||||
maxOutputChars: 20_000,
|
||||
inputFiles: context.pythonInputFiles,
|
||||
});
|
||||
|
||||
const s = stringifyToolResult(result);
|
||||
console.log("PYTHON_INTERPRETER_STRING_RESULT", s);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
const result = await handler(normalizeToolArguments(args));
|
||||
return stringifyToolResult(result);
|
||||
} catch (error) {
|
||||
return stringifyToolResult({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {runCommand} from "../../util/utils";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
|
||||
export const shellExecuteTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "shell_execute",
|
||||
description: "Execute command in a shell",
|
||||
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 unknown, 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?: Record<string, unknown>): Promise<string | undefined | null> {
|
||||
const cmd = asNonEmptyString(args?.cmd);
|
||||
if (!cmd) return undefined;
|
||||
|
||||
const {stdout, stderr} = await runCommand(cmd);
|
||||
|
||||
return stdout ?? stderr;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type ToolHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
@@ -0,0 +1,102 @@
|
||||
import {Ollama} from "ollama";
|
||||
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeToolArguments(args: unknown): Record<string, unknown> {
|
||||
if (!args) return {};
|
||||
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
raw: args,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof args === "object" && !Array.isArray(args)) {
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, 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: unknown, defaultValue = ""): string {
|
||||
return typeof value === "string" ? value : defaultValue;
|
||||
}
|
||||
|
||||
export function asPositiveInt(value: unknown, 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);
|
||||
console.log("All models have been requested to unload" + exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : ".");
|
||||
} catch (error) {
|
||||
console.error("Error unloading models:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> {
|
||||
try {
|
||||
await ollama.generate({
|
||||
model: model,
|
||||
stream: false,
|
||||
prompt: "",
|
||||
options: {
|
||||
num_ctx: contextLength
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Ollama model:", model);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import axios from "axios";
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {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?: Record<string, unknown>): Promise<any | null> {
|
||||
console.log("getWeather()");
|
||||
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];
|
||||
console.log("GEOCODE_RESPONSE", 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;
|
||||
console.log("RESPONSE: getWeather(lang=" + lang + "): ", 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 (e: any) {
|
||||
logError(e);
|
||||
return null;
|
||||
} finally {
|
||||
console.log("END: getWeather()");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user