import { tool } from "ai"; import { extract } from "botbrowser"; import { z } from "zod"; import type { MemoryStore } from "./memory.ts"; import type { CronScheduler } from "./scheduler.ts"; import type { Skill, SkillsManager } from "./skills.ts"; import { isValidRegistryRef } from "./skills.ts"; import type { AgentManager, MessageRouter, StreamEvent } from "./types.ts"; export type { AgentInfo, AgentManager, MessageRouter } from "./types.ts"; export interface ToolDeps { agentId: string; memoryStore: MemoryStore; getConversationId: () => string; getRouter: () => MessageRouter | null; getScheduler: () => CronScheduler; registerScheduleJob: (scheduleId: string, cronExpr: string, task: string) => boolean; getAgentManager: () => AgentManager | null; translateToCron: (timing: string) => Promise; getSkillsManager: () => SkillsManager; getActiveSkills: () => Skill[]; addActiveSkill: (skill: Skill) => void; emitToolEvent: (event: StreamEvent) => void; } const SHELL_TIMEOUT_MS = 30_000; const SHELL_KILL_GRACE_MS = 5_000; const SHELL_MAX_OUTPUT = 1024 * 1024; const MAX_CONTENT_LENGTH = 50_000; const READ_FILE_MAX = 100 * 1024; const SEARCH_TIMEOUT_MS = 5_000; const MAX_SNIPPET_LENGTH = 200; const DEFAULT_SEARXNG_INSTANCES = [ "https://searx.tiekoetter.com", "https://search.sapti.me", "https://priv.au", "https://opnxng.com", "https://search.indst.eu", ]; interface SearxngResult { title: string; url: string; content: string; engine: string; } let cachedInstance: string | null = null; export function resetCachedInstance(): void { cachedInstance = null; } function getSearxngInstances(): string[] { const envList = process.env.SEARXNG_INSTANCES; if (envList) { return envList .split(",") .map((s) => s.trim()) .filter(Boolean); } return DEFAULT_SEARXNG_INSTANCES; } export async function searxngSearch(query: string, numResults: number): Promise { const instances = getSearxngInstances(); const tryInstance = async (baseUrl: string): Promise => { const url = `${baseUrl}/search?q=${encodeURIComponent(query)}&format=json&categories=general&language=en`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS); try { const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const contentType = res.headers.get("content-type") ?? ""; if (!contentType.includes("json")) throw new Error("Response is not JSON"); const data = (await res.json()) as { results?: unknown }; if (!Array.isArray(data.results)) throw new Error("Unexpected response shape"); return data.results as SearxngResult[]; } finally { clearTimeout(timer); } }; if (cachedInstance) { try { return await tryInstance(cachedInstance); } catch { cachedInstance = null; } } for (const instance of instances) { try { const results = await tryInstance(instance); cachedInstance = instance; return results; } catch { continue; } } throw new Error( "Web search is currently unavailable — all SearXNG instances failed. Try using web_fetch directly if you have a URL.", ); } export function deduplicateResults(results: SearxngResult[]): SearxngResult[] { const seen = new Set(); const deduped: SearxngResult[] = []; for (const r of results) { if (!r.url || seen.has(r.url)) continue; seen.add(r.url); deduped.push(r); } return deduped; } export function formatSearchResults(results: SearxngResult[], numResults: number): string { const capped = results.slice(0, numResults); return capped .map((r, i) => { const title = r.title || "(untitled)"; let snippet = (r.content || "").trim(); if (snippet.length > MAX_SNIPPET_LENGTH) { snippet = `${snippet.slice(0, MAX_SNIPPET_LENGTH)}...`; } const parts = [`${i + 1}. ${title}`, ` URL: ${r.url}`]; if (snippet) parts.push(` ${snippet}`); return parts.join("\n"); }) .join("\n\n"); } export function createTools(deps: ToolDeps) { return { search_memory: tool({ description: "Search your long-term memory for relevant information. Use this to recall facts, preferences, or past decisions you've saved.", inputSchema: z.object({ query: z.string().describe("The search query"), }), execute: async ({ query }) => { try { const results = deps.memoryStore.searchMemory(query); if (results.length === 0) return "No relevant memories found."; return results.map((m) => `- ${m.content}`).join("\n"); } catch { return "No relevant memories found."; } }, }), save_to_memory: tool({ description: "Save an important fact, preference, or decision to your long-term memory. Use this when you learn something worth remembering across conversations.", inputSchema: z.object({ content: z.string().describe("The information to remember"), }), execute: async ({ content }) => { deps.memoryStore.saveMemory(content); return "Saved to memory."; }, }), send: tool({ description: "Send a message to another agent on your team and wait for their response. " + "Check the team roster in your system prompt to find the right agent_id. " + "Always include enough context for the receiving agent to act without follow-up questions.", inputSchema: z.object({ agent_id: z.string().describe("The ID of the agent to message (from your team roster)"), message: z.string().describe("The message to send — include full context"), }), execute: async ({ agent_id, message }) => { const router = deps.getRouter(); if (!router) throw new Error("No router available"); const convId = deps.getConversationId(); deps.memoryStore.saveMessage(convId, deps.agentId, agent_id, message); const response = await router(deps.agentId, agent_id, message); deps.memoryStore.saveMessage(convId, agent_id, deps.agentId, response); return response; }, }), schedule: tool({ description: "Create or update a recurring scheduled task. To update an existing schedule, pass its schedule_id.", inputSchema: z.object({ timing: z.string().describe("When to run, e.g. 'every 5 minutes', 'weekdays at 9am'"), task: z.string().describe("What to do each time the schedule fires"), schedule_id: z.string().optional().describe("Optional. Pass an existing schedule ID to update it."), }), execute: async ({ timing, task, schedule_id }) => { const cronExpr = await deps.translateToCron(timing); if (!schedule_id) { const existing = deps.memoryStore.getSchedules(); const dup = existing.find((s) => s.cron === cronExpr && s.task === task); if (dup) { return `An identical schedule already exists: '${dup.id}' (${dup.cron} — ${dup.description}). Use schedule_id '${dup.id}' to update it.`; } } const scheduleId = schedule_id || `sched-${crypto.randomUUID().slice(0, 8)}`; deps.memoryStore.saveSchedule(scheduleId, cronExpr, timing, task); deps.registerScheduleJob(scheduleId, cronExpr, task); const verb = schedule_id ? "Updated" : "Created"; return `${verb} schedule '${scheduleId}': "${task}" (${cronExpr} — ${timing})`; }, }), list_schedules: tool({ description: "List all active scheduled tasks. Use this to find schedule IDs before updating or deleting.", inputSchema: z.object({}), execute: async () => { const schedules = deps.memoryStore.getSchedules(); if (schedules.length === 0) return "No active schedules."; return schedules.map((s) => `- ${s.id}: "${s.task}" (${s.cron} — ${s.description})`).join("\n"); }, }), delete_schedule: tool({ description: "Delete/stop a recurring scheduled task by its schedule ID. Use list_schedules first to find the ID.", inputSchema: z.object({ schedule_id: z.string().describe("The ID of the schedule to delete"), }), execute: async ({ schedule_id }) => { const existed = deps.memoryStore.deleteSchedule(schedule_id); if (!existed) return `No schedule found with ID '${schedule_id}'.`; deps.getScheduler().unschedule(schedule_id); return `Deleted schedule '${schedule_id}'.`; }, }), list_agents: tool({ description: "List all registered agents with their current state, model, and uptime.", inputSchema: z.object({}), execute: async () => { const mgr = deps.getAgentManager(); if (!mgr) throw new Error("Agent management not available"); const agents = await mgr.listAgents(); if (agents.length === 0) return "No agents registered."; return agents .map( (a) => `- ${a.id} [${a.state}] (${a.model}) uptime: ${Math.floor(a.uptime / 1000)}s, messages: ${a.messageCount}`, ) .join("\n"); }, }), stop_agent: tool({ description: "Stop a running agent. The agent stays registered and can be restarted later. You cannot stop yourself.", inputSchema: z.object({ agent_id: z.string().describe("The ID of the agent to stop"), }), execute: async ({ agent_id }) => { const mgr = deps.getAgentManager(); if (!mgr) throw new Error("Agent management not available"); if (agent_id === deps.agentId) throw new Error("You cannot stop yourself."); const stopped = await mgr.stopAgent(agent_id); return stopped ? `Agent '${agent_id}' stopped.` : `Agent '${agent_id}' not found.`; }, }), start_agent: tool({ description: "Restart a stopped agent.", inputSchema: z.object({ agent_id: z.string().describe("The ID of the agent to start"), }), execute: async ({ agent_id }) => { const mgr = deps.getAgentManager(); if (!mgr) throw new Error("Agent management not available"); const info = await mgr.startAgent(agent_id); return `Agent '${agent_id}' started (${info.model}).`; }, }), remove_agent: tool({ description: "Permanently remove an agent and its memory. You cannot remove yourself.", inputSchema: z.object({ agent_id: z.string().describe("The ID of the agent to remove"), }), execute: async ({ agent_id }) => { const mgr = deps.getAgentManager(); if (!mgr) throw new Error("Agent management not available"); if (agent_id === deps.agentId) throw new Error("You cannot remove yourself."); const removed = await mgr.removeAgent(agent_id); return removed ? `Agent '${agent_id}' permanently removed.` : `Agent '${agent_id}' not found.`; }, }), spawn_agent: tool({ description: "Create and start a new agent from .soul file content. The content should be a valid .soul file body with MODEL and SOUL instructions.", inputSchema: z.object({ name: z.string().describe("The name/ID for the new agent (e.g. 'code-reviewer')"), content: z.string().describe("The full .soul file content as a string"), }), execute: async ({ name, content }) => { const mgr = deps.getAgentManager(); if (!mgr) throw new Error("Agent management not available"); const apiKey = mgr.getApiKey(deps.agentId); if (!apiKey) throw new Error("No API key available for spawning"); const info = await mgr.spawnAgent(name, content, apiKey); return `Agent '${info.id}' spawned and running (${info.model}).`; }, }), search_skills: tool({ description: "Search the skills.sh registry for installable skills. Returns the top results ranked by a blend of relevance and popularity, with descriptions and install counts. Prefer skills that are both relevant and well-adopted.", inputSchema: z.object({ query: z.string().describe("Search query (e.g. 'react performance', 'code review')"), }), execute: async ({ query }) => { return await deps.getSkillsManager().search(query); }, }), install_skill: tool({ description: "Install a skill from skills.sh and activate it. The skill's knowledge will be available on your next response. Use owner/repo@skill-name format.", inputSchema: z.object({ package: z .string() .describe("Skill reference in owner/repo@skill-name format (e.g. 'vercel-labs/agent-skills@code-review')"), }), execute: async (args) => { const pkg = args.package; if (!isValidRegistryRef(pkg)) { throw new Error(`Invalid package reference '${pkg}'. Must be owner/repo@skill-name format.`); } const active = deps.getActiveSkills(); if (active.some((s) => s.ref === pkg)) { return `Skill '${pkg}' is already installed and active.`; } const skill = await deps.getSkillsManager().resolve(pkg); deps.addActiveSkill(skill); return `Installed and activated skill '${skill.name}' (${pkg}). It will be available in your next response.`; }, }), list_skills: tool({ description: "List all locally installed skills, showing which ones are active for you.", inputSchema: z.object({}), execute: async () => { const installed = deps.getSkillsManager().listInstalled(); const active = deps.getActiveSkills(); const activeNames = new Set(active.map((s) => s.name)); if (installed.length === 0) return "No skills installed."; return installed .map((s) => { const status = activeNames.has(s.name) ? "[active]" : "[installed]"; return `- ${s.name} ${status}: ${s.description}`; }) .join("\n"); }, }), read_skill: tool({ description: "Read the full SKILL.md content of one of your active skills by name.", inputSchema: z.object({ name: z.string().describe("The name of the active skill to read"), }), execute: async ({ name }) => { const active = deps.getActiveSkills(); const skill = active.find((s) => s.name === name || s.ref.endsWith(`@${name}`)); if (!skill) { const names = active.map((s) => s.name); throw new Error( names.length > 0 ? `Skill '${name}' is not active. Active skills: ${names.join(", ")}` : "No active skills.", ); } return skill.content; }, }), create_skill: tool({ description: "Create a custom skill by writing SKILL.md content to your skills folder. The skill is activated immediately. Use this to persist reusable knowledge, workflows, or instructions.", inputSchema: z.object({ name: z.string().describe("Skill directory name (lowercase, hyphens, e.g. 'my-custom-skill')"), content: z.string().describe("Full SKILL.md content (markdown with optional frontmatter)"), }), execute: async ({ name, content }) => { if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { throw new Error("name must be lowercase alphanumeric with hyphens/underscores"); } const skill = deps.getSkillsManager().saveSkill(name, content); deps.addActiveSkill(skill); return `Created and activated skill '${skill.name}'.`; }, }), web_fetch: tool({ description: "Fetch a URL and return its content as clean markdown. Use this to read web pages, articles, documentation, or any publicly accessible URL. Pair with web_search to find URLs first.", inputSchema: z.object({ url: z.string().describe("The URL to fetch (must be http:// or https://)"), }), execute: async ({ url }) => { if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new Error("URL must start with http:// or https://"); } const result = await extract(url); if (!result.content || result.content.trim().length === 0) { throw new Error(`No readable content could be extracted from ${url}`); } let content = result.content; if (result.title) content = `# ${result.title}\n\n${content}`; if (content.length > MAX_CONTENT_LENGTH) { content = `${content.slice(0, MAX_CONTENT_LENGTH)}\n\n[Content truncated]`; } return content; }, }), web_search: tool({ description: "Search the web for real-time information. Returns a list of results with titles, URLs, and snippets. " + "Use this to find relevant pages, then use web_fetch to read the full content of specific URLs.", inputSchema: z.object({ query: z.string().describe("The search query"), num_results: z .number() .int() .min(1) .max(10) .optional() .describe("Number of results to return (default 5, max 10)"), }), execute: async ({ query, num_results }) => { const count = num_results ?? 5; const raw = await searxngSearch(query, count); const results = deduplicateResults(raw); if (results.length === 0) return `No results found for: ${query}`; return formatSearchResults(results, count); }, }), shell: tool({ description: "Execute a shell command inside your container. Commands run with bash. Use this for git, npm, grep, ls, or any CLI operation.", inputSchema: z.object({ command: z.string().describe("The shell command to execute"), working_directory: z.string().optional().describe("Working directory (defaults to /home)"), }), execute: async ({ command, working_directory }) => { const cwd = working_directory || "/home"; const proc = Bun.spawn(["bash", "-c", command], { cwd, stdout: "pipe", stderr: "pipe", }); const timer = setTimeout(() => { proc.kill("SIGTERM"); setTimeout(() => proc.kill("SIGKILL"), SHELL_KILL_GRACE_MS); }, SHELL_TIMEOUT_MS); const decoder = new TextDecoder(); let output = ""; let totalLen = 0; let truncated = false; try { const reader = proc.stdout.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); totalLen += text.length; if (totalLen > SHELL_MAX_OUTPUT) { const slice = text.slice(0, SHELL_MAX_OUTPUT - (totalLen - text.length)); output += slice; deps.emitToolEvent({ type: "tool_delta", text: slice }); deps.emitToolEvent({ type: "tool_delta", text: "\n[output truncated]" }); output += "\n[output truncated]"; truncated = true; break; } output += text; deps.emitToolEvent({ type: "tool_delta", text }); } } finally { clearTimeout(timer); } if (!truncated) { const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (stderr) { const stderrChunk = `${totalLen > 0 ? "\n" : ""}STDERR:\n${stderr}`; output += stderrChunk; deps.emitToolEvent({ type: "tool_delta", text: stderrChunk }); } if (exitCode !== 0) { const exitChunk = `\n[exit code ${exitCode}]`; output += exitChunk; deps.emitToolEvent({ type: "tool_delta", text: exitChunk }); } } return output || "(no output)"; }, }), read_file: tool({ description: "Read the contents of a file. Returns the full text content. Use this to inspect source code, config files, or any text file in your container.", inputSchema: z.object({ path: z.string().describe("Absolute path to the file to read"), }), execute: async ({ path }) => { const file = Bun.file(path); if (!(await file.exists())) throw new Error(`File not found: ${path}`); let content = await file.text(); if (content.length > READ_FILE_MAX) { content = `${content.slice(0, READ_FILE_MAX)}\n[content truncated]`; } return content; }, }), write_file: tool({ description: "Write content to a file, creating parent directories if needed. Use this to create or overwrite files in your container.", inputSchema: z.object({ path: z.string().describe("Absolute path to the file to write"), content: z.string().describe("The content to write"), }), execute: async ({ path, content }) => { const { mkdirSync } = await import("node:fs"); const { dirname } = await import("node:path"); mkdirSync(dirname(path), { recursive: true }); await Bun.write(path, content); return `Written ${content.length} bytes to ${path}`; }, }), }; }