feat: initial Lam Agent extension (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { formatNow, getLocationContext, weekdayName } from "./location";
|
||||
import { webFetch, webSearch } from "./ollama";
|
||||
import type { 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;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
content: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/** 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 the user's approximate location context: timezone, UTC offset, locale, region, and city. Use it for local questions such as weather, business hours, or travel timing. This is derived from the device timezone, not GPS, so treat it as a region rather than an address.",
|
||||
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<string, string> = {
|
||||
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<string, unknown>,
|
||||
context: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
switch (name) {
|
||||
case "get_datetime":
|
||||
return { content: describeNow(context.now), isError: false };
|
||||
|
||||
case "get_location":
|
||||
return { content: describeLocation(context.now), 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)
|
||||
? " The web tools require an Ollama Cloud host and an API key with web search enabled."
|
||||
: "";
|
||||
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");
|
||||
}
|
||||
|
||||
function describeLocation(now?: Date): string {
|
||||
const date = now ?? new Date();
|
||||
const location = getLocationContext(date);
|
||||
|
||||
const lines = [
|
||||
`Timezone: ${location.timezone}`,
|
||||
`UTC offset: ${location.utcOffset}`,
|
||||
`Locale: ${location.locale}`,
|
||||
];
|
||||
if (location.city) lines.push(`City: ${location.city}`);
|
||||
if (location.region) lines.push(`Region: ${location.region}`);
|
||||
lines.push("Note: derived from the device timezone. It indicates region, not a precise address.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function runWebSearch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
|
||||
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 rendered = results
|
||||
.map((result, index) => {
|
||||
const snippet = result.content.replace(/\s+/g, " ").trim().slice(0, MAX_SNIPPET_CHARS);
|
||||
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
|
||||
}
|
||||
|
||||
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
|
||||
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 sections = [`Title: ${page.title}`, `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 };
|
||||
}
|
||||
Reference in New Issue
Block a user