import { createSourceList, type SourceList } from "./citations"; 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 four are read-only. That is a deliberate boundary for this version: an * assistant that can silently act on the user's machine needs a confirmation * story first, and this release is chat-only. Anything with a side effect * (opening URLs, clipboard access, launching apps) belongs behind an approval * step that does not exist yet. */ 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; } /** * 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"], }, }, }, ]; /** 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"]); /** 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", }; export function toolLabel(name: string): string { return TOOL_LABELS[name] ?? `Running ${name}`; } /** Filters out the web tools when the user has turned them off. */ export function toolsFor(options: { webEnabled: boolean }): ToolDefinition[] { return TOOL_DEFINITIONS.filter((tool) => options.webEnabled || !WEB_TOOL_NAMES.has(tool.function.name)); } 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); } /** * 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); 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] : [] }; }