import { createSourceList, type SourceList } from "./citations"; import { deletePath, editTextFile, listDirectory, movePath, readTextFile, searchFiles, writeTextFile, type ApproveFn, type FileResult, type PathPolicy, } from "./files"; import { formatNow, getLocationContext, weekdayName } from "./location"; import { webFetch, webSearch } from "./ollama"; import type { GeoLookup } from "./geo"; import type { Source, ToolDefinition } from "./types"; import { errorMessage } from "./utils"; /** * The tools Lam can call. * * All are read-only except the file tools, which are the one family here that can * change the machine. They are bounded in three ways at once: they are absent from * the tool list unless the user switches file access on, the policy in `files.ts` * decides what they may touch, and every change is approved in a dialog before it * happens. Nothing here opens a URL, touches the clipboard or launches a program: * those would be side effects with no approval story of their own. */ export interface ToolContext { host: string; apiKey: string; signal?: AbortSignal; /** Injectable clock so tests are deterministic. */ now?: Date; /** * The user's Location preference, if they set one. * * Injected rather than read here, because this module is deliberately free of * Vicinae imports and preferences arrive through that API. */ place?: string; /** * Resolves a rough place from the user's IP address, when that is switched on. * * Injected for the same reason as `place`, and absent when IP geolocation is * off, which is what keeps this module from being able to reach the network * for a location on its own. */ ipPlace?: (options: { signal?: AbortSignal }) => Promise; /** * The turn's source list, when the caller keeps one. * * Optional, so a caller with no interest in citations (a unit test, or any * future tool that returns no sources) still gets the same numbered output: a * list is made on the spot when none is supplied. */ sources?: SourceList; /** * The file tools' policy and approval step, when file access is switched on. * * Both arrive together because neither is usable alone: the policy decides what may * be touched and the approval decides whether it happens. Absent means the tools are * not offered at all, and a call that arrives anyway is refused. */ files?: { policy: PathPolicy; approve: ApproveFn }; } /** * What a tool returned. * * `sources` is separate from `content` on purpose. The content is prose written * for a model to read, while the sources are data the transcript renders, so a * change to one format cannot quietly break the other. */ export interface ToolResult { content: string; isError: boolean; /** * The sources this call added, for the transcript to list under the answer. * * Only what this call found, never the turn's running total: that total is the * flattening of the turn's records, so storing it here would write the same page * once per later call. */ sources?: Source[]; } /** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */ const MAX_SNIPPET_CHARS = 1200; const MAX_FETCH_CHARS = 8000; export const TOOL_DEFINITIONS: ToolDefinition[] = [ { type: "function", function: { name: "get_datetime", description: "Get the user's current local date and time, including the weekday, timezone, and UTC offset. Use this whenever an answer depends on what today's date is or what time it is now.", parameters: { type: "object", properties: {}, required: [] }, }, }, { type: "function", function: { name: "get_location", description: "Get where the user is, for local questions such as weather, business hours, or travel timing. Returns the place they configured in preferences, plus the timezone, UTC offset, locale and region. Without a configured place this is region-level, not an address, and the timezone area it reports is the timezone's own name rather than the user's city, so never treat it as one.", parameters: { type: "object", properties: {}, required: [] }, }, }, { type: "function", function: { name: "web_search", description: "Search the web for current information. Use it for recent events, prices, releases, documentation changes, or anything that may have happened after your training cutoff. Returns a list of titles, URLs, and content snippets.", parameters: { type: "object", properties: { query: { type: "string", description: "The search query. Be specific." }, max_results: { type: "integer", description: "How many results to return, between 1 and 10. Defaults to 5.", }, }, required: ["query"], }, }, }, { type: "function", function: { name: "web_fetch", description: "Fetch a single web page and return its title, main content as markdown, and the links on the page. Use it after web_search to read a promising result in full, or directly when the user supplies a URL.", parameters: { type: "object", properties: { url: { type: "string", description: "Absolute URL to fetch, including the scheme." }, }, required: ["url"], }, }, }, { type: "function", function: { name: "list_directory", description: "List what is in a folder on the user's machine: names, which entries are directories, their sizes and how long ago they changed. Use it before reading a file to get the exact name, and to see what a folder holds.", parameters: { type: "object", properties: { path: { type: "string", description: "Absolute path, or one starting with ~. A relative path is read as relative to the user's home directory.", }, hidden: { type: "boolean", description: "Include dotfiles. Defaults to false." }, }, required: ["path"], }, }, }, { type: "function", function: { name: "search_files", description: "Find files by name under a folder, optionally only those whose contents contain a string. Use it when you do not know where something is. Credential files are never returned.", parameters: { type: "object", properties: { path: { type: "string", description: "Folder to search, absolute or starting with ~." }, pattern: { type: "string", description: 'Glob for the file name, such as "*.md" or "report*". Use "*" for everything.', }, contains: { type: "string", description: "Only files whose contents contain this text, case-insensitively.", }, }, required: ["path", "pattern"], }, }, }, { type: "function", function: { name: "read_file", description: "Read a text file. Lines come back numbered, and a long file comes back in parts: use offset and limit to continue from where the last read stopped rather than asking for the whole thing. Binary files are refused.", parameters: { type: "object", properties: { path: { type: "string", description: "File to read, absolute or starting with ~." }, offset: { type: "integer", description: "First line to return, counting from 1." }, limit: { type: "integer", description: "How many lines to return. Defaults to 200." }, }, required: ["path"], }, }, }, { type: "function", function: { name: "write_file", description: "Create a file, or replace one entirely, with the content you supply. The user approves every change in a dialog before it happens, and a refusal is final: do not retry it, say what you could not do instead. Prefer edit_file for a small change to an existing file.", parameters: { type: "object", properties: { path: { type: "string", description: "File to write, absolute or starting with ~." }, content: { type: "string", description: "The complete new contents of the file." }, }, required: ["path", "content"], }, }, }, { type: "function", function: { name: "edit_file", description: "Replace an exact string in an existing file, leaving the rest of it alone. old_string must appear exactly once, or pass all to replace every occurrence. Include enough surrounding text to make it unique, and never guess at the contents: read the file first.", parameters: { type: "object", properties: { path: { type: "string", description: "File to edit, absolute or starting with ~." }, old_string: { type: "string", description: "The exact text to replace, copied from the file.", }, new_string: { type: "string", description: "The text to put in its place." }, all: { type: "boolean", description: "Replace every occurrence rather than requiring one.", }, }, required: ["path", "old_string", "new_string"], }, }, }, { type: "function", function: { name: "delete_path", description: "Delete one file, or one empty folder. A folder with anything in it is refused, because Lam never deletes recursively. The user approves every deletion and it cannot be undone.", parameters: { type: "object", properties: { path: { type: "string", description: "File or empty folder to delete." }, }, required: ["path"], }, }, }, { type: "function", function: { name: "move_path", description: "Move or rename a file or folder. Both ends must be somewhere Lam may change, the destination must not already exist, and the user approves every move.", parameters: { type: "object", properties: { from: { type: "string", description: "Current path." }, to: { type: "string", description: "New path, which must not exist yet." }, }, required: ["from", "to"], }, }, }, ]; /** Tools that need Ollama Cloud. A local Ollama server does not implement these endpoints. */ export const WEB_TOOL_NAMES = new Set(["web_search", "web_fetch"]); /** Tools that read or change files, offered only when the user has switched file access on. */ export const FILE_TOOL_NAMES = new Set([ "list_directory", "search_files", "read_file", "write_file", "edit_file", "delete_path", "move_path", ]); /** Human-readable labels for the activity line in the UI. */ export const TOOL_LABELS: Record = { get_datetime: "Checking the time", get_location: "Checking your location", web_search: "Searching the web", web_fetch: "Reading a page", list_directory: "Listing a folder", search_files: "Searching your files", read_file: "Reading a file", write_file: "Writing a file", edit_file: "Editing a file", delete_path: "Deleting a file", move_path: "Moving a file", }; export function toolLabel(name: string): string { return TOOL_LABELS[name] ?? `Running ${name}`; } /** Filters out the tools the user has switched off. */ export function toolsFor(options: { webEnabled: boolean; fileEnabled?: boolean }): ToolDefinition[] { return TOOL_DEFINITIONS.filter((tool) => { const name = tool.function.name; if (WEB_TOOL_NAMES.has(name)) return options.webEnabled; if (FILE_TOOL_NAMES.has(name)) return options.fileEnabled ?? false; return true; }); } function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function asCount(value: unknown, min: number, max: number): number | undefined { const parsed = typeof value === "number" ? value : Number(value); if (!Number.isFinite(parsed)) return undefined; return Math.min(max, Math.max(min, Math.trunc(parsed))); } function isWebTool(name: string): boolean { return WEB_TOOL_NAMES.has(name); } /** The path argument every file tool needs, as the model wrote it. */ function requirePath(args: Record): string { return typeof args.path === "string" ? args.path : ""; } function missing(tool: string, argument: string): ToolResult { return { content: `${tool} requires a "${argument}".`, isError: true }; } /** * Runs one file operation, once the context is known to have a policy. * * The missing-policy branch is not dead code: `toolsFor` keeps these tools out of the * list when file access is off, so a call only arrives here if a model invents one, and * then the honest answer is that the tool is not available. */ async function runFileTool( context: ToolContext, run: (files: { policy: PathPolicy; approve: ApproveFn }) => Promise, ): Promise { const files = context.files; if (!files) { return { content: "File tools are not available in this session. The user has not switched file access on.", isError: true, }; } const result = await run(files); // A missing argument is answered here rather than by the policy, so the callback is // allowed to return either shape and this is where the two meet. if ("isError" in result) return result; return { content: result.content, isError: !result.ok }; } /** * Runs one tool call and always resolves. * * Tool failures are returned as `isError: true` rather than thrown, because the * model should get a chance to recover, retry with different arguments, or * explain the failure to the user. Throwing here would abort the whole turn * over something the model can often route around. */ export async function executeTool( name: string, args: Record, context: ToolContext, ): Promise { try { switch (name) { case "get_datetime": return { content: describeNow(context.now), isError: false }; case "get_location": return { content: await describeLocation(context), isError: false }; case "web_search": return await runWebSearch(args, context); case "web_fetch": return await runWebFetch(args, context); case "list_directory": return await runFileTool(context, (files) => listDirectory(requirePath(args), files.policy, { hidden: args.hidden === true }), ); case "search_files": return await runFileTool(context, (files) => { const pattern = asString(args.pattern); if (!pattern) return Promise.resolve(missing("search_files", "pattern")); return searchFiles( { path: requirePath(args), pattern, contains: asString(args.contains) }, files.policy, ); }); case "read_file": return await runFileTool(context, (files) => readTextFile( { path: requirePath(args), offset: asCount(args.offset, 1, Number.MAX_SAFE_INTEGER), limit: asCount(args.limit, 1, 5000), }, files.policy, ), ); case "write_file": return await runFileTool(context, (files) => writeTextFile( { path: requirePath(args), content: typeof args.content === "string" ? args.content : "", }, files.policy, files.approve, ), ); case "edit_file": return await runFileTool(context, (files) => { const oldString = typeof args.old_string === "string" ? args.old_string : undefined; const newString = typeof args.new_string === "string" ? args.new_string : ""; if (oldString === undefined) return Promise.resolve(missing("edit_file", "old_string")); return editTextFile( { path: requirePath(args), oldString, newString, all: args.all === true }, files.policy, files.approve, ); }); case "delete_path": return await runFileTool(context, (files) => deletePath(requirePath(args), files.policy, files.approve), ); case "move_path": return await runFileTool(context, (files) => { // `from` rather than `path`: this is the one file tool with two paths, and // reading the shared helper here would have sent an empty source path. const from = asString(args.from); const to = asString(args.to); if (!from) return Promise.resolve(missing("move_path", "from")); if (!to) return Promise.resolve(missing("move_path", "to")); return movePath({ from, to }, files.policy, files.approve); }); default: return { content: `Unknown tool "${name}".`, isError: true }; } } catch (error) { const suffix = isWebTool(name) ? " Web search needs Ollama Cloud, or a local Ollama server new enough to serve it (0.35 and later)." : ""; return { content: `${errorMessage(error)}${suffix}`, isError: true }; } } function describeNow(now?: Date): string { const date = now ?? new Date(); const location = getLocationContext(date); return [ `Current date and time: ${formatNow(date, location.timezone)}`, `Weekday: ${weekdayName(date, location.timezone)}`, `Timezone: ${location.timezone} (${location.utcOffset})`, `Unix timestamp: ${Math.floor(date.getTime() / 1000)}`, `ISO 8601 (UTC): ${date.toISOString()}`, ].join("\n"); } async function describeLocation(context: ToolContext): Promise { const date = context.now ?? new Date(); const configured = context.place?.trim() || undefined; // A place the user typed wins outright, and while it is set there is no reason // to hand their IP address to anyone. const lookup = configured || !context.ipPlace ? undefined : await context.ipPlace({ signal: context.signal }); const ip = lookup?.status === "ok" ? lookup.place : undefined; const location = getLocationContext(date, undefined, configured ?? ip?.place); const lines: string[] = []; // The place comes first because it is the only answer that is about where the // user actually is, rather than about which zone the clock is set to. if (location.place) lines.push(`Place: ${location.place}`); lines.push( `Timezone: ${location.timezone}`, `UTC offset: ${location.utcOffset}`, `Locale: ${location.locale}`, ); if (location.region) { lines.push(`Region: ${location.region}${location.regionCode ? ` (${location.regionCode})` : ""}`); } if (location.timezoneArea) { lines.push(`Timezone area: ${location.timezoneArea}`); } // A mismatch between the clock and the IP is worth surfacing: it usually means // a VPN or a device still set to somewhere the user has left. if (ip?.timezone && ip.timezone !== location.timezone) { lines.push(`IP-reported timezone: ${ip.timezone}, which differs from the device timezone`); } if (configured) { lines.push( "Note: Place is the user's own setting, so prefer it for anything that depends on exactly where they are.", ); } else if (ip) { lines.push( `Note: Place came from an IP address lookup via ${ip.source}, so treat it as a rough guess rather than the user's city. An IP address locates where the request left the ISP's network, which can be hundreds of kilometres away, and it can be out of date if they have moved since.`, ); } else if (lookup?.status === "failed") { // Deliberately not the "no Location is set" wording below. A failed lookup // and an unset preference are different problems, and reporting the wrong // one sends the user looking in the wrong place. This is also the only // channel that reaches them: the model paraphrases, so it is told to say so. lines.push( `Note: IP geolocation is switched on but the lookup failed (${lookup.message}), so no place could be resolved. Tell the user this plainly. Setting Location in preferences avoids the lookup and the network entirely. Everything below is derived from the device timezone and locale, so it is region-level.`, ); } else { lines.push( "Note: no Location is set in preferences. Everything above is derived from the device timezone and locale, so it is accurate to a region and not to a city. Ask which city they are in when an exact place matters.", ); } if (location.timezoneArea) { lines.push("The timezone area is the timezone's own name, not the user's city."); } return lines.join("\n"); } async function runWebSearch(args: Record, context: ToolContext): Promise { const query = asString(args.query); if (!query) { return { content: 'web_search requires a "query" string.', isError: true }; } const results = await webSearch({ host: context.host, apiKey: context.apiKey, query, maxResults: asCount(args.max_results, 1, 10), signal: context.signal, }); if (results.length === 0) { return { content: `No web results for "${query}".`, isError: false }; } const list = context.sources ?? createSourceList(); const added: Source[] = []; const rendered = results .map((result) => { const collapsed = result.content.replace(/\s+/g, " ").trim(); // Mark the cut rather than silently dropping the tail: the model should // know a snippet was trimmed, and this matches what web_fetch does. const snippet = collapsed.length > MAX_SNIPPET_CHARS ? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]` : collapsed; const { index, source } = list.add(result.title, result.url); if (source) added.push(source); // The number is what the model cites, so it is printed rather than left to be // inferred from position: three searches in one turn would otherwise each // start again at 1, and a marker in the answer would point at three pages. const marker = index > 0 ? `[${index}] ` : ""; return `${marker}${result.title}\n ${result.url}\n ${snippet}`; }) .join("\n\n"); return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added }; } async function runWebFetch(args: Record, context: ToolContext): Promise { const url = asString(args.url); if (!url) { return { content: 'web_fetch requires a "url" string.', isError: true }; } const page = await webFetch({ host: context.host, apiKey: context.apiKey, url, signal: context.signal, }); const content = page.content.slice(0, MAX_FETCH_CHARS); const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : ""; const links = page.links.slice(0, 20); const { index, source } = (context.sources ?? createSourceList()).add(page.title, url); const heading = index > 0 ? `[${index}] ${page.title}` : page.title; // The address keeps its label here, unlike in a search result: the body after it // can run for thousands of characters, and a reader scrolling that wants to be // able to find the page's own URL again. const sections = [heading, `URL: ${url}`, "", content + truncated]; if (links.length > 0) { sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`); } return { content: sections.join("\n"), isError: false, sources: source ? [source] : [] }; }