/** * Shared agent tool factory functions. * * Extracted from TaskExecutor so they can be reused by other subsystems * (e.g., HeartbeatMonitor execution) without pulling in the full executor. * * The parameter schemas are canonical here — executor.ts imports and reuses them. */ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, relative, resolve } from "node:path"; import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core"; import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core"; import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchProviderRegistry } from "./research/provider-registry.js"; import { ResearchStepRunner } from "./research-step-runner.js"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@mariozechner/pi-ai"; import type { AgentReflectionService } from "./agent-reflection.js"; import { createLogger } from "./logger.js"; import { fetchWebContent, WebFetchError } from "./web-fetch.js"; import type { RunAuditor } from "./run-audit.js"; import { computeApprovalDedupeKey } from "./agent-action-gate.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── const TASK_CREATE_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const; export const taskCreateParams = Type.Object({ description: Type.String({ description: "What needs to be done" }), dependencies: Type.Optional( Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }), ), priority: Type.Optional( Type.Union(TASK_CREATE_PRIORITY_VALUES.map((priority) => Type.Literal(priority)), { description: "Task priority (low, normal, high, urgent)", }), ), }); export const taskLogParams = Type.Object({ message: Type.String({ description: "What happened" }), outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })), }); export const taskDocumentWriteParams = Type.Object({ key: Type.String({ description: "Document key (e.g., 'plan', 'notes', 'research'). Alphanumeric, hyphens, underscores, 1-64 chars.", }), content: Type.String({ description: "Document content to store" }), author: Type.Optional(Type.String({ description: "Who is writing (default: 'agent')" })), }); export const taskDocumentReadParams = Type.Object({ key: Type.Optional( Type.String({ description: "Document key to read. Omit to list all documents for this task." }), ), }); export const reflectOnPerformanceParams = Type.Object({ focus_area: Type.Optional( Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }), ), }); export const readEvaluationsParams = Type.Object({}); export const updateIdentityParams = Type.Object({ soul: Type.Optional(Type.String({ description: "Updated soul/personality text" })), instructionsText: Type.Optional(Type.String({ description: "Updated operating instructions" })), memory: Type.Optional(Type.String({ description: "Updated agent memory text" })), }); export const listAgentsParams = Type.Object({ role: Type.Optional( Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }), ), state: Type.Optional( Type.String({ description: "Filter by agent state (e.g., 'idle', 'active', 'running')" }), ), includeEphemeral: Type.Optional( Type.Boolean({ description: "Include ephemeral/runtime agents (default: false)" }), ), }); export const delegateTaskParams = Type.Object({ agent_id: Type.String({ description: "The agent ID to delegate work to" }), description: Type.String({ description: "What needs to be done" }), dependencies: Type.Optional( Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }), ), override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), }); export const getAgentConfigParams = Type.Object({ agent_id: Type.String({ description: "The agent ID to read configuration for" }), }); export const updateAgentConfigParams = Type.Object({ agent_id: Type.String({ description: "The agent ID to update" }), soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })), instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions", maxLength: 50000 })), instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown file", maxLength: 500 })), heartbeat_procedure_path: Type.Optional(Type.String({ description: "Path to heartbeat procedure markdown file", maxLength: 500 })), heartbeat_interval_ms: Type.Optional(Type.Number({ description: "Heartbeat polling interval in ms", minimum: 1000 })), heartbeat_timeout_ms: Type.Optional(Type.Number({ description: "Heartbeat timeout in ms", minimum: 5000 })), max_concurrent_runs: Type.Optional(Type.Number({ description: "Max concurrent heartbeat runs", minimum: 1 })), message_response_mode: Type.Optional(Type.Union([ Type.Literal("immediate"), Type.Literal("on-heartbeat"), ], { description: "How agent responds to messages" })), }); export const createAgentParams = Type.Object({ name: Type.String({ description: "Name for the new agent" }), role: Type.Union([ Type.Literal("triage"), Type.Literal("executor"), Type.Literal("reviewer"), Type.Literal("merger"), Type.Literal("engineer"), Type.Literal("custom"), ], { description: "Agent role/capability" }), soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })), instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions", maxLength: 50000 })), instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown file", maxLength: 500 })), reportsTo: Type.Optional(Type.String({ description: "Manager agent ID. Defaults to the calling agent." })), heartbeat_interval_ms: Type.Optional(Type.Number({ description: "Heartbeat polling interval in ms", minimum: 1000 })), heartbeat_timeout_ms: Type.Optional(Type.Number({ description: "Heartbeat timeout in ms", minimum: 5000 })), max_concurrent_runs: Type.Optional(Type.Number({ description: "Max concurrent heartbeat runs", minimum: 1 })), message_response_mode: Type.Optional(Type.Union([ Type.Literal("immediate"), Type.Literal("on-heartbeat"), ], { description: "How agent responds to messages" })), }); export const deleteAgentParams = Type.Object({ agent_id: Type.String({ description: "Agent ID to delete" }), force: Type.Optional(Type.Boolean({ description: "Force delete even if the agent currently holds a checkout lease" })), reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent ID for tasks currently assigned to the deleted agent" })), }); export const sendMessageParams = Type.Object({ to_id: Type.String({ description: "Recipient ID (agent ID or user ID, depending on message type)" }), content: Type.String({ description: "Message body (1-2000 characters)" }), type: Type.Optional(Type.Union([ Type.Literal("agent-to-agent"), Type.Literal("agent-to-user"), ], { description: "Message type (defaults to 'agent-to-agent')" })), reply_to_message_id: Type.Optional( Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }), ), }); export const readMessagesParams = Type.Object({ unread_only: Type.Optional(Type.Boolean({ description: "Only return unread messages (default: true)" })), limit: Type.Optional(Type.Number({ description: "Max messages to return (default: 20)" })), }); export const postRoomMessageParams = Type.Object({ roomId: Type.String({ description: "Room ID to post into" }), content: Type.String({ description: "Room message body (1-2000 characters)" }), replyToMessageId: Type.Optional(Type.String({ description: "Optional ID of the room message you are replying to" })), mentions: Type.Optional(Type.Array(Type.String(), { description: "Optional agent IDs to mention in the room message" })), }); export const memorySearchParams = Type.Object({ query: Type.String({ description: "Search terms for durable project memory. Use focused keywords, not a full prompt." }), limit: Type.Optional(Type.Number({ description: "Maximum snippets to return (default: 5, max: 20)" })), }); export const memoryGetParams = Type.Object({ path: Type.String({ description: "Memory path from fn_memory_search, e.g. .fusion/memory/MEMORY.md or .fusion/memory/YYYY-MM-DD.md" }), startLine: Type.Optional(Type.Number({ description: "1-based start line (default: 1)" })), lineCount: Type.Optional(Type.Number({ description: "Number of lines to read (default: 120, max: 400)" })), }); export const webFetchParams = Type.Object({ url: Type.String({ description: "URL to fetch (http/https only)" }), prompt: Type.Optional(Type.String({ description: "Optional extraction hint for downstream summarization" })), timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds" })), maxBytes: Type.Optional(Type.Number({ description: "Maximum content bytes to return" })), }); export const researchRunParams = Type.Object({ query: Type.String({ description: "Research question or topic to investigate" }), wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for completion in this call (default: false)" })), max_wait_ms: Type.Optional(Type.Number({ description: "Max wait time when wait_for_completion=true (default: 90000, capped by settings)" })), }); export const researchListParams = Type.Object({ status: Type.Optional(Type.Union([ Type.Literal("pending"), Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("cancelled"), ], { description: "Optional status filter" })), limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })), }); export const researchGetParams = Type.Object({ id: Type.String({ description: "Research run ID" }), }); export const researchCancelParams = Type.Object({ id: Type.String({ description: "Research run ID to cancel" }), }); export const memoryAppendParams = Type.Object({ scope: Type.Optional(Type.Union([ Type.Literal("project"), Type.Literal("agent"), ], { description: "project for workspace memory, agent for this agent's private memory" })), layer: Type.Union([ Type.Literal("long-term"), Type.Literal("daily"), ], { description: "long-term for durable conventions/decisions/pitfalls, daily for running notes/open loops" }), content: Type.String({ description: "Markdown content to append. Keep it concise and reusable." }), }); type MemoryToolSettings = { memoryBackendType?: string; [key: string]: unknown; }; type AgentMemoryContext = { agentId: string; agentName?: string; memory?: string | null; }; type MemoryToolOptions = { agentMemory?: AgentMemoryContext; }; type MemorySearchHit = { path: string; lineStart: number; lineEnd: number; snippet: string; score: number; backend: string; }; const log = createLogger("agent-tools"); const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000; const MAX_MEMORY_LENGTH = 50_000; const MAX_SOUL_LENGTH = 10_000; const AGENT_MEMORY_ROOT = ".fusion/agent-memory"; const AGENT_MEMORY_FILENAME = "MEMORY.md"; const AGENT_DREAMS_FILENAME = "DREAMS.md"; const agentQmdRefreshState = new Map }>(); const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000; const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/; export function sanitizeAgentMemoryId(agentId: string): string { return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent"; } export function agentMemoryDisplayPath(agentId: string): string { return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`; } function agentDreamsDisplayPath(agentId: string): string { return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_DREAMS_FILENAME}`; } function agentMemoryDirectory(rootDir: string, agentId: string): string { return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId)); } export function agentMemoryFilePath(rootDir: string, agentId: string): string { return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME); } function agentDreamsFilePath(rootDir: string, agentId: string): string { return join(agentMemoryDirectory(rootDir, agentId), AGENT_DREAMS_FILENAME); } function agentDailyFilePath(rootDir: string, agentId: string, date = new Date()): string { return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`); } export async function readAgentMemoryWorkspaceLongTerm(rootDir: string, agentId: string): Promise { const safeRoot = typeof rootDir === "string" ? rootDir.trim() : ""; const safeAgentId = typeof agentId === "string" ? agentId.trim() : ""; if (!safeRoot || !safeAgentId) { return ""; } const filePath = agentMemoryFilePath(safeRoot, safeAgentId); try { const fileStat = await stat(filePath); if (!fileStat.isFile()) { return ""; } const content = await readFile(filePath, "utf-8"); return typeof content === "string" ? content.trim() : ""; } catch { return ""; } } export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string { const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12); return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`; } export function buildQmdAgentMemoryCollectionAddArgs(rootDir: string, agentId: string): string[] { return [ "collection", "add", agentMemoryDirectory(rootDir, agentId), "--name", qmdAgentMemoryCollectionName(rootDir, agentId), "--mask", "**/*.md", ]; } export function buildQmdAgentMemorySearchArgs(rootDir: string, agentId: string, query: string, limit = 5): string[] { return [ "search", query, "--json", "--collection", qmdAgentMemoryCollectionName(rootDir, agentId), "-n", String(Math.max(1, Math.min(limit, 20))), ]; } async function syncAgentMemoryFile(rootDir: string, agentMemory?: AgentMemoryContext): Promise { const content = agentMemory?.memory?.trim(); if (!agentMemory?.agentId) { return null; } const dir = agentMemoryDirectory(rootDir, agentMemory.agentId); await mkdir(dir, { recursive: true }); const longTermPath = agentMemoryFilePath(rootDir, agentMemory.agentId); if (!existsSync(longTermPath)) { const title = agentMemory.agentName?.trim() ? `# Agent Memory: ${agentMemory.agentName.trim()}` : "# Agent Memory"; const fileContent = `${title}\n\n\n\n${content || ""}\n`; await writeFile(longTermPath, fileContent, "utf-8"); } const dreamsPath = agentDreamsFilePath(rootDir, agentMemory.agentId); if (!existsSync(dreamsPath)) { await writeFile(dreamsPath, "# Agent Memory Dreams\n\n\n", "utf-8"); } const dailyPath = agentDailyFilePath(rootDir, agentMemory.agentId); if (!existsSync(dailyPath)) { await writeFile(dailyPath, `# Agent Daily Memory ${new Date().toISOString().slice(0, 10)}\n\n\n`, "utf-8"); } return agentMemoryDisplayPath(agentMemory.agentId); } async function listAgentMemoryFiles(rootDir: string, agentMemory: AgentMemoryContext): Promise> { await syncAgentMemoryFile(rootDir, agentMemory); const dir = agentMemoryDirectory(rootDir, agentMemory.agentId); const files = [ { absPath: agentMemoryFilePath(rootDir, agentMemory.agentId), displayPath: agentMemoryDisplayPath(agentMemory.agentId) }, { absPath: agentDreamsFilePath(rootDir, agentMemory.agentId), displayPath: agentDreamsDisplayPath(agentMemory.agentId) }, ]; let entries: string[]; try { entries = await readdir(dir); } catch (err) { log.warn(`Failed to read agent memory directory ${dir}: ${err instanceof Error ? err.message : String(err)}`); entries = []; } for (const entry of entries) { if (!DAILY_AGENT_MEMORY_RE.test(entry)) continue; const absPath = join(dir, entry); const fileStat = await stat(absPath); if (fileStat.isFile()) { files.push({ absPath, displayPath: `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentMemory.agentId)}/${entry}`, }); } } return files; } function scoreAgentMemorySnippet(snippet: string, query: string): number { const terms = query.toLowerCase().split(/[^a-z0-9_-]+/i).filter((term) => term.length >= 2); const normalized = snippet.toLowerCase(); return terms.reduce((score, term) => score + (normalized.includes(term) ? 1 : 0), 0); } async function searchAgentMemoryFile(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise { const displayPath = await syncAgentMemoryFile(rootDir, agentMemory); if (!displayPath) { return []; } const results: MemorySearchHit[] = []; for (const file of await listAgentMemoryFiles(rootDir, agentMemory)) { const content = await readFile(file.absPath, "utf-8"); const lines = content.split("\n"); for (let index = 0; index < lines.length; index += 8) { const chunk = lines.slice(index, index + 12).join("\n").trim(); if (!chunk) continue; const score = scoreAgentMemorySnippet(chunk, query); if (score === 0) continue; results.push({ path: file.displayPath, lineStart: index + 1, lineEnd: Math.min(index + 12, lines.length), snippet: chunk.slice(0, 1200), score: score + 1000, backend: "agent-memory", }); } } return results.slice(0, limit); } async function refreshAgentMemoryQmdIndex(rootDir: string, agentMemory: AgentMemoryContext): Promise { if (shouldSkipBackgroundQmdRefresh()) { return; } await syncAgentMemoryFile(rootDir, agentMemory); const key = `${rootDir}:${agentMemory.agentId}`; const now = Date.now(); const current = agentQmdRefreshState.get(key); if (current?.inFlight) { return current.inFlight; } if (current && now - current.lastStartedAt < AGENT_QMD_REFRESH_INTERVAL_MS) { return; } const promise = (async () => { const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const execFileAsync = promisify(execFile); try { await execFileAsync("qmd", buildQmdAgentMemoryCollectionAddArgs(rootDir, agentMemory.agentId), { cwd: rootDir, timeout: 4000, maxBuffer: 512 * 1024, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); const stderr = typeof error === "object" && error && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : ""; if (!/already exists|exists/i.test(`${message}\n${stderr}`)) { throw error; } } await execFileAsync("qmd", ["update"], { cwd: rootDir, timeout: 30_000, maxBuffer: 1024 * 1024 }); await execFileAsync("qmd", ["embed"], { cwd: rootDir, timeout: 120_000, maxBuffer: 1024 * 1024 }); })(); agentQmdRefreshState.set(key, { lastStartedAt: now, inFlight: promise }); try { await promise; } finally { const latest = agentQmdRefreshState.get(key); if (latest?.inFlight === promise) { agentQmdRefreshState.set(key, { lastStartedAt: latest.lastStartedAt }); } } } function normalizeQmdAgentMemoryResultPath(rootDir: string, agentId: string, rawPath: unknown): string { const fallbackPath = agentMemoryDisplayPath(agentId); const original = String(rawPath ?? "").trim(); if (!original) { return fallbackPath; } let candidate = original.replace(/\\/g, "/"); const uriMatch = candidate.match(/^qmd:\/\/[^/]+\/(.+)$/i); if (uriMatch?.[1]) { candidate = uriMatch[1]; } candidate = candidate.split("?")[0]?.split("#")[0] ?? ""; candidate = candidate.replace(/^\.\/+/, ""); const normalizedAgentId = sanitizeAgentMemoryId(agentId); const agentPrefix = `${AGENT_MEMORY_ROOT}/${normalizedAgentId}/`; if (candidate.startsWith(agentPrefix)) { return resolveAgentMemoryPath(rootDir, agentId, candidate)?.displayPath ?? fallbackPath; } const workspacePath = resolve(agentMemoryDirectory(rootDir, agentId)).replace(/\\/g, "/"); const candidateAbs = resolve(rootDir, candidate).replace(/\\/g, "/"); const relToWorkspace = relative(workspacePath, candidateAbs).replace(/\\/g, "/"); if (relToWorkspace && !relToWorkspace.startsWith("..") && !relToWorkspace.includes("/../")) { const maybeDisplayPath = `${agentPrefix}${relToWorkspace}`; return resolveAgentMemoryPath(rootDir, agentId, maybeDisplayPath)?.displayPath ?? fallbackPath; } const filename = candidate.split("/").pop() ?? ""; if (filename.toLowerCase() === AGENT_MEMORY_FILENAME.toLowerCase()) { return agentMemoryDisplayPath(agentId); } if (filename.toLowerCase() === AGENT_DREAMS_FILENAME.toLowerCase()) { return agentDreamsDisplayPath(agentId); } if (DAILY_AGENT_MEMORY_RE.test(filename)) { return `${agentPrefix}${filename}`; } return fallbackPath; } async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise { if (shouldSkipBackgroundQmdRefresh()) { return searchAgentMemoryFile(rootDir, agentMemory, query, limit); } try { await refreshAgentMemoryQmdIndex(rootDir, agentMemory); const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const execFileAsync = promisify(execFile); const { stdout } = await execFileAsync("qmd", buildQmdAgentMemorySearchArgs(rootDir, agentMemory.agentId, query, limit), { cwd: rootDir, timeout: 4000, maxBuffer: 1024 * 1024, }); const parsed = JSON.parse(stdout); const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : []; return rawResults.slice(0, limit).map((result: Record) => { const rawPath = result.path ?? result.file; return { path: normalizeQmdAgentMemoryResultPath(rootDir, agentMemory.agentId, rawPath), lineStart: Number(result.lineStart ?? result.startLine ?? 1), lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1), snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(0, 1200), score: Number(result.score ?? 1) + 1000, backend: "qmd-agent-memory", }; }).filter((result: MemorySearchHit) => result.snippet.trim().length > 0); } catch (err) { log.warn( `QMD agent memory search failed for agent ${agentMemory.agentId}, falling back to file search: ${err instanceof Error ? err.message : String(err)}`, ); return searchAgentMemoryFile(rootDir, agentMemory, query, limit); } } function resolveAgentMemoryPath(rootDir: string, agentId: string, path: string): { absPath: string; displayPath: string } | null { const safeAgentId = sanitizeAgentMemoryId(agentId); const prefix = `${AGENT_MEMORY_ROOT}/${safeAgentId}/`; if (!path.startsWith(prefix)) { return null; } const filename = path.slice(prefix.length); if (filename !== AGENT_MEMORY_FILENAME && filename !== AGENT_DREAMS_FILENAME && !DAILY_AGENT_MEMORY_RE.test(filename)) { return null; } return { absPath: join(agentMemoryDirectory(rootDir, agentId), filename), displayPath: `${prefix}${filename}`, }; } async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryContext, path: string, startLine = 1, lineCount = 40) { const resolved = resolveAgentMemoryPath(rootDir, agentMemory.agentId, path); if (!resolved) { return null; } await syncAgentMemoryFile(rootDir, agentMemory); const content = await readFile(resolved.absPath, "utf-8"); const lines = content.split("\n"); const start = Math.max(1, Math.floor(startLine)); const count = Math.max(1, Math.min(Math.floor(lineCount), 200)); const startIndex = Math.min(start - 1, lines.length); const endIndex = Math.min(startIndex + count, lines.length); return { path: resolved.displayPath, content: lines.slice(startIndex, endIndex).join("\n"), startLine: start, endLine: endIndex, totalLines: lines.length, backend: "agent-memory", }; } // ── Tool factory functions ──────────────────────────────────────────────── type AgentTaskCreationOptions = { rootDir?: string; }; export async function createAgentTask( store: TaskStore, input: TaskCreateInput, options?: AgentTaskCreationOptions, ): Promise>> { const settings = typeof (store as { getSettings?: unknown }).getSettings === "function" ? await store.getSettings() : {} as Settings; const rootDir = options?.rootDir; return store.createTask(input, { settings: { autoSummarizeTitles: settings.autoSummarizeTitles === true }, onSummarize: rootDir ? async (description: string) => { const resolved = resolveTitleSummarizerSettingsModel(settings); return summarizeTitle(description, rootDir, resolved.provider, resolved.modelId); } : undefined, }); } /** * Create a `fn_task_create` tool that creates a new task in triage. * * @param store - TaskStore for task persistence * @returns ToolDefinition for the `fn_task_create` tool */ export function createTaskCreateTool( store: TaskStore, provenance?: { sourceType: SourceType; sourceAgentId?: string; sourceRunId?: string }, options?: AgentTaskCreationOptions, ): ToolDefinition { return { name: "fn_task_create", label: "Create Task", description: "Create a new task for out-of-scope work discovered during execution. " + "The task goes into triage where it will be specified by the AI. " + "Optionally set dependencies (e.g., the new task depends on the current one, " + "or the current task should wait for the new one).", parameters: taskCreateParams, execute: async (_id: string, params: Static) => { try { const task = await createAgentTask(store, { description: params.description, dependencies: params.dependencies, column: "triage", priority: params.priority, source: provenance ? { sourceType: provenance.sourceType, sourceAgentId: provenance.sourceAgentId, sourceRunId: provenance.sourceRunId, } : undefined, }, options); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; return { content: [{ type: "text" as const, text: `Created ${task.id}: ${params.description}${deps}`, }], details: { taskId: task.id }, }; } catch (err) { if (err instanceof Error && err.message.startsWith("Task ID already exists:")) { return { content: [{ type: "text" as const, text: `ERROR: ${err.message}` }], details: {}, isError: true, }; } throw err; } }, }; } /** * Create a `fn_task_log` tool that logs an entry for a specific task. * * @param store - TaskStore for task persistence * @param taskId - The task ID to log entries against * @returns ToolDefinition for the `fn_task_log` tool */ export function createTaskLogTool(store: TaskStore, taskId: string): ToolDefinition { return { name: "fn_task_log", label: "Log Entry", description: "Log an important action, decision, or issue for this task. " + "Use for significant events — not every small step.", parameters: taskLogParams, execute: async (_id: string, params: Static) => { try { await store.logEntry(taskId, params.message, params.outcome); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (typeof err?.message === "string" && err.message.toLowerCase().includes("archived")) { return { content: [{ type: "text" as const, text: "ERROR: Cannot log to archived task — this task is read-only" }], details: {}, }; } throw err; } return { content: [{ type: "text" as const, text: `Logged: ${params.message}` }], details: {}, }; }, }; } /** * Create a `fn_task_log` tool with run context for mutation correlation. * * @param store - TaskStore for task persistence * @param taskId - The task ID to log entries against * @param runContext - Optional run context for mutation correlation * @returns ToolDefinition for the `fn_task_log` tool */ export function createTaskLogToolWithContext(store: TaskStore, taskId: string, runContext?: RunMutationContext): ToolDefinition { return { name: "fn_task_log", label: "Log Entry", description: "Log an important action, decision, or issue for this task. " + "Use for significant events — not every small step.", parameters: taskLogParams, execute: async (_id: string, params: Static) => { try { await store.logEntry(taskId, params.message, params.outcome, runContext); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (typeof err?.message === "string" && err.message.toLowerCase().includes("archived")) { return { content: [{ type: "text" as const, text: "ERROR: Cannot log to archived task — this task is read-only" }], details: {}, }; } throw err; } return { content: [{ type: "text" as const, text: `Logged: ${params.message}` }], details: {}, }; }, }; } /** * Create a `fn_task_document_write` tool that stores a named task document. * * @param store - TaskStore for task document persistence * @param taskId - The task ID to write documents against * @returns ToolDefinition for the `fn_task_document_write` tool */ export function createTaskDocumentWriteTool(store: TaskStore, taskId: string): ToolDefinition { return { name: "fn_task_document_write", label: "Write Document", description: "Save a named document for this task (for example plan, notes, or research). " + "Each write creates a new revision so you can update documents over time.", parameters: taskDocumentWriteParams, execute: async (_id: string, params: Static) => { const input: TaskDocumentCreateInput = { key: params.key, content: params.content, author: params.author || "agent", }; try { const document: TaskDocument = await store.upsertTaskDocument(taskId, input); return { content: [{ type: "text" as const, text: `Saved document "${document.key}" (revision ${document.revision}).`, }], details: {}, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to save document "${params.key}": ${err.message}`, }], details: {}, }; } }, }; } /** * Create a `fn_task_document_read` tool that reads task-scoped documents. * * @param store - TaskStore for task document reads * @param taskId - The task ID to read documents from * @returns ToolDefinition for the `fn_task_document_read` tool */ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): ToolDefinition { return { name: "fn_task_document_read", label: "Read Document", description: "Read a named document for this task, or list all documents when no key is provided.", parameters: taskDocumentReadParams, execute: async (_id: string, params: Static) => { try { if (params.key) { const document: TaskDocument | null = await store.getTaskDocument(taskId, params.key); if (!document) { return { content: [{ type: "text" as const, text: `Document "${params.key}" not found.` }], details: {}, }; } return { content: [{ type: "text" as const, text: `Document: ${document.key}\n` + `Revision: ${document.revision}\n` + `Updated: ${document.updatedAt}\n\n` + document.content, }], details: {}, }; } const documents: TaskDocument[] = await store.getTaskDocuments(taskId); if (documents.length === 0) { return { content: [{ type: "text" as const, text: "No documents found for this task." }], details: {}, }; } const lines = documents.map((doc) => `- ${doc.key} (revision ${doc.revision}, updated ${doc.updatedAt})`); return { content: [{ type: "text" as const, text: `Task documents:\n${lines.join("\n")}`, }], details: {}, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to read task documents: ${err.message}`, }], details: {}, }; } }, }; } export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition { return { name: "fn_memory_search", label: "Search Memory", description: "Search durable project memory and this agent's own memory, returning small snippets with file paths and line ranges. " + "Use this before fn_memory_get; do not read all memory by default.", parameters: memorySearchParams, execute: async (_id: string, params: Static) => { const limit = params.limit ?? 5; const agentResults = options?.agentMemory ? resolveMemoryBackend(settings).type === "qmd" ? await searchAgentMemoryWithQmd(rootDir, options.agentMemory, params.query, limit) : await searchAgentMemoryFile(rootDir, options.agentMemory, params.query, limit) : []; const projectResults = await searchProjectMemory(rootDir, { query: params.query, limit, }, settings); const results = [...agentResults, ...projectResults] .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)) .slice(0, limit); if (results.length === 0) { return { content: [{ type: "text" as const, text: "NONE" }], details: { results: [] }, }; } const text = results.map((result, index) => [ `${index + 1}. ${result.path}:${result.lineStart}-${result.lineEnd} (score ${result.score}, ${result.backend})`, result.snippet, ].join("\n")).join("\n\n"); return { content: [{ type: "text" as const, text }], details: { results } }; }, }; } export function createMemoryGetTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition { return { name: "fn_memory_get", label: "Get Memory", description: "Read a bounded line window from a memory file returned by fn_memory_search. " + "Allowed files include project memory under .fusion/memory/ and this agent's own .fusion/agent-memory/{agentId}/MEMORY.md file.", parameters: memoryGetParams, execute: async (_id: string, params: Static) => { const agentResult = options?.agentMemory ? await getAgentMemoryWindow(rootDir, options.agentMemory, params.path, params.startLine, params.lineCount) : null; if (agentResult) { return { content: [{ type: "text" as const, text: `${agentResult.path}:${agentResult.startLine}-${agentResult.endLine} (${agentResult.totalLines} total lines, ${agentResult.backend})\n\n${agentResult.content}`, }], details: agentResult, }; } const result = await getProjectMemory(rootDir, { path: params.path, startLine: params.startLine, lineCount: params.lineCount, }, settings); return { content: [{ type: "text" as const, text: `${result.path}:${result.startLine}-${result.endLine} (${result.totalLines} total lines, ${result.backend})\n\n${result.content}`, }], details: result, }; }, }; } export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition { return { name: "fn_memory_append", label: "Append Memory", description: "Append concise Markdown to memory. Use scope=\"agent\" for private operating context and scope=\"project\" for workspace-wide durable knowledge. " + "Use layer=\"long-term\" for durable conventions/decisions/pitfalls and layer=\"daily\" for running observations/open loops.", parameters: memoryAppendParams, execute: async (_id: string, params: Static) => { const content = params.content.trim(); if (!content) { return { content: [{ type: "text" as const, text: "ERROR: memory content cannot be empty" }], details: {} }; } const scope = params.scope ?? "project"; if (scope === "agent") { if (!options?.agentMemory) { return { content: [{ type: "text" as const, text: "ERROR: agent memory is not available in this session" }], details: {} }; } const agentMemory = options.agentMemory; await syncAgentMemoryFile(rootDir, agentMemory); const targetPath = params.layer === "long-term" ? agentMemoryFilePath(rootDir, agentMemory.agentId) : agentDailyFilePath(rootDir, agentMemory.agentId); await appendFile(targetPath, `\n${content}\n`, "utf-8"); if (resolveMemoryBackend(settings).type === "qmd") { void refreshAgentMemoryQmdIndex(rootDir, agentMemory).catch((err) => { log.warn( `Agent memory QMD index refresh failed for ${agentMemory.agentId}: ${err instanceof Error ? err.message : String(err)}`, ); }); } return { content: [{ type: "text" as const, text: `Appended to agent ${params.layer} memory.` }], details: { scope, layer: params.layer }, }; } await ensureOpenClawMemoryFiles(rootDir); const targetPath = params.layer === "long-term" ? memoryLongTermPath(rootDir) : dailyMemoryPath(rootDir); await appendFile(targetPath, `\n${content}\n`, "utf-8"); if (resolveMemoryBackend(settings).type === "qmd") { scheduleQmdProjectMemoryRefresh(rootDir); } return { content: [{ type: "text" as const, text: `Appended to ${params.layer} memory.` }], details: { scope, layer: params.layer }, }; }, }; } export function createWebFetchTool(options?: { allowPrivateHosts?: boolean }): ToolDefinition { return { name: "fn_web_fetch", label: "WebFetch", description: "Fetch and extract readable text from a URL (lightweight HTTP fetch, no JS rendering).", parameters: webFetchParams, execute: async (_id: string, params: Static) => { try { const result = await fetchWebContent(params.url, { timeoutMs: params.timeoutMs, maxBytes: params.maxBytes, allowPrivateHosts: options?.allowPrivateHosts ?? false, }); const sections = [ `URL: ${result.finalUrl}`, `Status: ${result.status}`, `Content-Type: ${result.contentType}`, params.prompt ? `Prompt: ${params.prompt}` : undefined, result.title ? `Title: ${result.title}` : undefined, "", result.content, result.truncated ? "\n[truncated to maxBytes]" : "", ].filter(Boolean); return { content: [{ type: "text" as const, text: sections.join("\n") }], details: { finalUrl: result.finalUrl, status: result.status, contentType: result.contentType, title: result.title, truncated: result.truncated, bytesRead: result.bytesRead, prompt: params.prompt, }, }; } catch (error) { if (error instanceof WebFetchError) { return { content: [{ type: "text" as const, text: `ERROR [${error.code}]: ${error.message}` }], details: { code: error.code, message: error.message }, isError: true, }; } const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text" as const, text: `ERROR [network-error]: ${message}` }], details: { code: "network-error", message }, isError: true, }; } }, }; } export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition[] { if (settings?.memoryEnabled === false) { return []; } const tools = [ createMemorySearchTool(rootDir, settings, options), createMemoryGetTool(rootDir, settings, options), ]; if (getMemoryBackendCapabilities(settings).writable) { tools.push(createMemoryAppendTool(rootDir, settings, options)); } return tools; } /** * Create a `fn_reflect_on_performance` tool that asks the reflection service to * analyze recent agent performance and return actionable insights. */ export function createReflectOnPerformanceTool( reflectionService: AgentReflectionService, agentId: string, ): ToolDefinition { return { name: "fn_reflect_on_performance", label: "Reflect on Performance", description: 'Review your past task performance and generate insights for improvement. Optionally focus on a specific area like "code quality", "speed", or "testing".', parameters: reflectOnPerformanceParams, execute: async (_id: string, params: Static) => { const triggerDetail = params.focus_area ? `Agent-initiated reflection focused on: ${params.focus_area}` : "Agent-initiated reflection"; const reflection = await reflectionService.generateReflection(agentId, "manual", { triggerDetail, }); if (!reflection) { return { content: [{ type: "text" as const, text: "No reflection data available — not enough history yet." }], details: {}, }; } const formattedText = [ `Summary: ${reflection.summary}`, "", "Insights:", ...reflection.insights.map((insight, index) => `${index + 1}. ${insight}`), "", "Suggested Improvements:", ...reflection.suggestedImprovements.map((improvement, index) => `${index + 1}. ${improvement}`), ].join("\n"); return { content: [{ type: "text" as const, text: formattedText }], details: {}, }; }, }; } /** * Create a `fn_list_agents` tool that lists all available agents. * * @param agentStore - AgentStore for agent discovery * @returns ToolDefinition for the `fn_list_agents` tool */ function formatScore(score: number | null | undefined): string { if (typeof score !== "number" || !Number.isFinite(score)) return "n/a"; return score.toFixed(2); } function buildPreview(value: string, limit = 100): string { const trimmed = value.trim(); return trimmed.length > limit ? `${trimmed.slice(0, limit)}…` : trimmed; } export function createReadEvaluationsTool( agentStore: AgentStore, reflectionStore: ReflectionStore | undefined, agentId: string, ): ToolDefinition { return { name: "fn_read_evaluations", label: "Read Evaluations", description: "Read your ratings, recent feedback, and reflection history to support self-improvement.", parameters: readEvaluationsParams, execute: async (_id: string, _params: Static) => { const [summary, ratings] = await Promise.all([ agentStore.getRatingSummary(agentId), agentStore.getRatings(agentId, { limit: 10 }), ]); const latestReflection = reflectionStore ? await reflectionStore.getLatestReflection(agentId) : null; const reflections = reflectionStore ? await reflectionStore.getReflections(agentId, 5) : []; const hasRatings = ratings.length > 0 || summary.totalRatings > 0; const hasReflections = Boolean(latestReflection) || reflections.length > 0; if (!hasRatings && !hasReflections) { return { content: [{ type: "text" as const, text: "No evaluation data available yet." }], details: {}, }; } const lines: string[] = [ "Evaluation Summary", `- Average score: ${formatScore(summary.averageScore)}`, `- Trend: ${summary.trend}`, `- Total ratings: ${summary.totalRatings}`, ]; const categoryEntries = Object.entries(summary.categoryAverages ?? {}); if (categoryEntries.length > 0) { lines.push("", "Category averages:"); for (const [category, score] of categoryEntries) { lines.push(`- ${category}: ${formatScore(score)}`); } } const commentedRatings = ratings.filter((rating) => rating.comment?.trim()); if (commentedRatings.length > 0) { lines.push("", "Recent rating comments:"); for (const rating of commentedRatings.slice(0, 5)) { lines.push(`- [${rating.score}/5] ${rating.comment!.trim()}`); } } if (latestReflection) { lines.push("", "Latest reflection:", `- Summary: ${latestReflection.summary}`); if (latestReflection.insights.length > 0) { lines.push("- Insights:"); latestReflection.insights.forEach((insight) => lines.push(` - ${insight}`)); } if (latestReflection.suggestedImprovements.length > 0) { lines.push("- Suggested improvements:"); latestReflection.suggestedImprovements.forEach((item) => lines.push(` - ${item}`)); } } if (reflections.length > 0) { lines.push("", "Recent reflection history:"); for (const reflection of reflections.slice(0, 5)) { lines.push(`- ${reflection.timestamp}: ${reflection.summary}`); } } return { content: [{ type: "text" as const, text: lines.join("\n") }], details: {}, }; }, }; } export function createUpdateIdentityTool(agentStore: AgentStore, agentId: string): ToolDefinition { return { name: "fn_update_identity", label: "Update Identity", description: "Update your own soul, instructionsText, or memory fields based on evaluation feedback.", parameters: updateIdentityParams, execute: async (_id: string, params: Static) => { const updates: AgentUpdateInput = {}; if (params.soul !== undefined) { const soul = params.soul.trim(); if (soul.length > MAX_SOUL_LENGTH) { return { content: [{ type: "text" as const, text: `ERROR: soul exceeds ${MAX_SOUL_LENGTH} character limit` }], details: {}, }; } updates.soul = soul; } if (params.instructionsText !== undefined) { const instructionsText = params.instructionsText.trim(); if (instructionsText.length > MAX_INSTRUCTIONS_TEXT_LENGTH) { return { content: [{ type: "text" as const, text: `ERROR: instructionsText exceeds ${MAX_INSTRUCTIONS_TEXT_LENGTH} character limit` }], details: {}, }; } updates.instructionsText = instructionsText; } if (params.memory !== undefined) { const memory = params.memory.trim(); if (memory.length > MAX_MEMORY_LENGTH) { return { content: [{ type: "text" as const, text: `ERROR: memory exceeds ${MAX_MEMORY_LENGTH} character limit` }], details: {}, }; } updates.memory = memory; } if (Object.keys(updates).length === 0) { return { content: [{ type: "text" as const, text: "ERROR: Provide at least one field to update" }], details: {}, }; } await agentStore.updateAgent(agentId, updates); const confirmations = Object.entries(updates).map(([key, value]) => `- ${key}: ${buildPreview(String(value))}`); return { content: [{ type: "text" as const, text: `Updated identity fields:\n${confirmations.join("\n")}`, }], details: { updatedFields: Object.keys(updates) }, }; }, }; } export function createListAgentsTool(agentStore: AgentStore): ToolDefinition { return { name: "fn_list_agents", label: "List Agents", description: "List all available agents in the system. Shows each agent's name, role, state, " + "personality (soul), and current assignment. Use this to discover which agents exist " + "and what they specialize in before delegating work.", parameters: listAgentsParams, execute: async (_id: string, params: Static) => { const filter: { role?: AgentCapability; state?: AgentState; includeEphemeral?: boolean } = {}; if (params.role) filter.role = params.role as AgentCapability; if (params.state) filter.state = params.state as AgentState; if (params.includeEphemeral !== undefined) filter.includeEphemeral = params.includeEphemeral; const agents = await agentStore.listAgents(filter); if (agents.length === 0) { return { content: [{ type: "text" as const, text: "No agents found matching the specified filters." }], details: {}, }; } const lines = agents.map((agent) => { const parts: string[] = [ `ID: ${agent.id}`, `Name: ${agent.name}`, `Role: ${agent.role}`, `State: ${agent.state}`, ]; if (agent.title) parts.push(`Title: ${agent.title}`); if (agent.soul) parts.push(`Soul: ${agent.soul.slice(0, 200)}`); if (agent.instructionsText) { const snippet = agent.instructionsText.slice(0, 100); parts.push(`Custom Instructions: ${snippet}${agent.instructionsText.length > 100 ? "…" : ""}`); } if (agent.taskId) parts.push(`Current Task: ${agent.taskId}`); return parts.join("\n"); }); return { content: [{ type: "text" as const, text: `Available agents:\n\n${lines.join("\n\n")}` }], details: { agents }, }; }, }; } /** * Create a `fn_delegate_task` tool that creates and assigns a task to a specific agent. * * @param agentStore - AgentStore for agent lookup * @param taskStore - TaskStore for task creation * @returns ToolDefinition for the `fn_delegate_task` tool */ export function createGetAgentConfigTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition { return { name: "fn_get_agent_config", label: "Get Agent Config", description: "Read full configuration for one of your direct-report agents.", parameters: getAgentConfigParams, execute: async (_id: string, params: Static) => { const target = await agentStore.getAgent(params.agent_id); if (!target) { return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {}, }; } if (target.reportsTo !== callingAgentId) { return { content: [{ type: "text" as const, text: "ERROR: You can only read configuration of agents that report to you" }], details: {}, }; } const runtimeConfig = (target.runtimeConfig ?? {}) as Record; const lines: string[] = [ `Agent Config: ${target.name} (${target.id})`, `Role: ${target.role}`, `State: ${target.state}`, `Title: ${target.title ?? "(none)"}`, `Icon: ${target.icon ?? "(none)"}`, "", "Soul:", target.soul ?? "(none)", "", "Instructions Text:", target.instructionsText ?? "(none)", `Instructions Path: ${target.instructionsPath ?? "(none)"}`, `Heartbeat Procedure Path: ${target.heartbeatProcedurePath ?? "(none)"}`, "", "Runtime Config:", `heartbeatIntervalMs: ${String(runtimeConfig.heartbeatIntervalMs ?? "(default)")}`, `heartbeatTimeoutMs: ${String(runtimeConfig.heartbeatTimeoutMs ?? "(default)")}`, `maxConcurrentRuns: ${String(runtimeConfig.maxConcurrentRuns ?? "(default)")}`, `messageResponseMode: ${String(runtimeConfig.messageResponseMode ?? "(default)")}`, `budget: ${JSON.stringify(runtimeConfig.budget ?? null)}`, "", "Memory:", target.memory ?? "(none)", ]; return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { agent: target }, }; }, }; } function isCallerPrivileged(caller: { id: string; role: string; reportsTo?: string | null } | null): boolean { if (!caller) return false; return caller.role === "ceo" || caller.reportsTo == null; } export function createUpdateAgentConfigTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition { return { name: "fn_update_agent_config", label: "Update Agent Config", description: "Update configuration for one of your direct-report agents.", parameters: updateAgentConfigParams, execute: async (_id: string, params: Static) => { const target = await agentStore.getAgent(params.agent_id); if (!target) { return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {}, }; } if (target.reportsTo !== callingAgentId) { return { content: [{ type: "text" as const, text: "ERROR: You can only update configuration of agents that report to you" }], details: {}, }; } if (isEphemeralAgent(target)) { return { content: [{ type: "text" as const, text: `ERROR: Cannot update ephemeral/runtime agent ${params.agent_id}` }], details: {}, }; } if (params.soul && params.soul.length > 10000) { return { content: [{ type: "text" as const, text: "ERROR: soul exceeds 10000 character limit" }], details: {}, }; } if (params.instructions_text && params.instructions_text.length > 50000) { return { content: [{ type: "text" as const, text: "ERROR: instructions_text exceeds 50000 character limit" }], details: {}, }; } if (params.instructions_path && params.instructions_path.length > 500) { return { content: [{ type: "text" as const, text: "ERROR: instructions_path exceeds 500 character limit" }], details: {}, }; } if (params.heartbeat_procedure_path && params.heartbeat_procedure_path.length > 500) { return { content: [{ type: "text" as const, text: "ERROR: heartbeat_procedure_path exceeds 500 character limit" }], details: {}, }; } const hasRuntimeConfigUpdates = [ params.heartbeat_interval_ms, params.heartbeat_timeout_ms, params.max_concurrent_runs, params.message_response_mode, ].some((value) => value !== undefined); const updateInput: AgentUpdateInput = {}; if (params.soul !== undefined) updateInput.soul = params.soul; if (params.instructions_text !== undefined) updateInput.instructionsText = params.instructions_text; if (params.instructions_path !== undefined) updateInput.instructionsPath = params.instructions_path; if (params.heartbeat_procedure_path !== undefined) updateInput.heartbeatProcedurePath = params.heartbeat_procedure_path; if (hasRuntimeConfigUpdates) { updateInput.runtimeConfig = { ...((target.runtimeConfig ?? {}) as Record), ...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}), ...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}), ...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}), ...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}), }; } if (Object.keys(updateInput).length === 0) { return { content: [{ type: "text" as const, text: "ERROR: Provide at least one field to update" }], details: {}, }; } const updated = await agentStore.updateAgent(params.agent_id, updateInput); const updatedRuntimeConfig = (updated.runtimeConfig ?? {}) as Record; return { content: [{ type: "text" as const, text: `Updated ${updated.name} (${updated.id})\n` + `heartbeatIntervalMs: ${String(updatedRuntimeConfig.heartbeatIntervalMs ?? "(default)")}\n` + `heartbeatTimeoutMs: ${String(updatedRuntimeConfig.heartbeatTimeoutMs ?? "(default)")}\n` + `maxConcurrentRuns: ${String(updatedRuntimeConfig.maxConcurrentRuns ?? "(default)")}\n` + `messageResponseMode: ${String(updatedRuntimeConfig.messageResponseMode ?? "(default)")}`, }], details: { agent: updated }, }; }, }; } /** * Create a `fn_delegate_task` tool that creates and assigns a task to a specific agent. * * @param agentStore - AgentStore for agent lookup * @param taskStore - TaskStore for task creation * @returns ToolDefinition for the `fn_delegate_task` tool */ type AgentProvisioningToolOptions = { hireApprovalEnabled?: boolean; approvalRequestStore?: ApprovalRequestStore; settingsProvider?: () => Promise; runAuditor?: RunAuditor; }; export function createAgentCreateTool( agentStore: AgentStore, callingAgentId: string, options?: AgentProvisioningToolOptions, ): ToolDefinition { return { name: "fn_agent_create", label: "Create Agent", description: "Create a new non-ephemeral direct-report agent.", parameters: createAgentParams, execute: async (_id: string, params: Static) => { const caller = await agentStore.getAgent(callingAgentId); const privileged = isCallerPrivileged(caller); const reportsTo = params.reportsTo ?? callingAgentId; if (!privileged && reportsTo !== callingAgentId) { return { content: [{ type: "text" as const, text: "ERROR: You can only create agents that report to you" }], details: {}, }; } const settings = await options?.settingsProvider?.(); const fallbackSettings = !options?.settingsProvider && !options?.approvalRequestStore ? { agentProvisioning: { approvalMode: "never" as const } } : settings; const policy = resolveAgentProvisioningPolicy({ tool: "fn_agent_create", caller: caller ? { id: caller.id, role: caller.role, isPrivileged: privileged } : undefined, settings: fallbackSettings, }); await options?.runAuditor?.database({ type: "agent:create:requested", target: callingAgentId, metadata: { policy } }); if (policy.decision === "require-approval") { if (!options?.approvalRequestStore) { await options?.runAuditor?.database({ type: "agent:create:denied", target: callingAgentId, metadata: { policy, reason: "approval-store-missing" } }); return { content: [{ type: "text" as const, text: `DENIED: agent provisioning requires approval but approval storage is unavailable (${policy.matchedRule})` }], details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode }, }; } const approvalDedupeKey = computeApprovalDedupeKey({ agentId: callingAgentId, toolName: "fn_agent_create", category: "agent_provisioning", resourceType: "agent", resourceId: reportsTo, operation: `create:${params.name}:${params.role}:${reportsTo}`, }); const request = options.approvalRequestStore.create({ requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId }, targetAction: { category: "agent_provisioning", action: "create", summary: `Create agent ${params.name} (${params.role})`, resourceType: "agent", resourceId: "", context: { tool: "fn_agent_create", params, approvalDedupeKey }, }, }); return { content: [{ type: "text" as const, text: `Approval required to create agent ${params.name}. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode }, }; } if (policy.decision === "deny") { await options?.runAuditor?.database({ type: "agent:create:denied", target: callingAgentId, metadata: { policy } }); return { content: [{ type: "text" as const, text: `DENIED: agent provisioning blocked by policy (${policy.matchedRule})` }], details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode }, }; } const runtimeConfig: Record = { ...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}), ...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}), ...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}), ...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}), }; const created = await agentStore.createAgent({ name: params.name, role: params.role, ...(params.soul !== undefined ? { soul: params.soul } : {}), ...(params.instructions_text !== undefined ? { instructionsText: params.instructions_text } : {}), ...(params.instructions_path !== undefined ? { instructionsPath: params.instructions_path } : {}), reportsTo, ...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}), }); if (options?.hireApprovalEnabled) { await agentStore.updateAgentState(created.id, "paused"); await agentStore.updateAgent(created.id, { metadata: { ...(created.metadata ?? {}), pendingApproval: true }, }); } await options?.runAuditor?.database({ type: "agent:create:approved", target: created.id, metadata: { policy, autoApproved: true } }); return { content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})${options?.hireApprovalEnabled ? " in pending_approval" : ""}` }], details: { outcome: "created", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agent: created, agentId: created.id, pendingApproval: options?.hireApprovalEnabled === true }, }; }, }; } export function createAgentDeleteTool( agentStore: AgentStore, callingAgentId: string, options?: AgentProvisioningToolOptions, ): ToolDefinition { return { name: "fn_agent_delete", label: "Delete Agent", description: "Delete one of your direct-report non-ephemeral agents.", parameters: deleteAgentParams, execute: async (_id: string, params: Static) => { const caller = await agentStore.getAgent(callingAgentId); const target = await agentStore.getAgent(params.agent_id); if (!target) { return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: { outcome: "denied", matchedRule: "missing-target", effectiveMode: "trusted-only", agentId: params.agent_id }, }; } const privileged = isCallerPrivileged(caller); if (!privileged && target.reportsTo !== callingAgentId) { return { content: [{ type: "text" as const, text: "ERROR: You can only delete agents that report to you" }], details: {}, }; } if (isEphemeralAgent(target)) { return { content: [{ type: "text" as const, text: `ERROR: Cannot delete ephemeral/runtime agent ${params.agent_id}` }], details: {} }; } const settings = await options?.settingsProvider?.(); const fallbackSettings = !options?.settingsProvider && !options?.approvalRequestStore ? { agentProvisioning: { approvalMode: "never" as const } } : settings; const policy = resolveAgentProvisioningPolicy({ tool: "fn_agent_delete", caller: caller ? { id: caller.id, role: caller.role, isPrivileged: privileged } : undefined, settings: fallbackSettings, }); await options?.runAuditor?.database({ type: "agent:delete:requested", target: target.id, metadata: { policy } }); if (policy.decision === "require-approval") { if (!options?.approvalRequestStore) { await options?.runAuditor?.database({ type: "agent:delete:denied", target: target.id, metadata: { policy, reason: "approval-store-missing" } }); return { content: [{ type: "text" as const, text: `DENIED: agent delete requires approval but approval storage is unavailable (${policy.matchedRule})` }], details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id }, }; } const approvalDedupeKey = computeApprovalDedupeKey({ agentId: callingAgentId, toolName: "fn_agent_delete", category: "agent_provisioning", resourceType: "agent", resourceId: target.id, operation: `delete:${target.id}:${params.force === true ? "force" : "normal"}:${params.reassign_to ?? ""}`, }); const request = options.approvalRequestStore.create({ requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId }, targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${target.name} (${target.id})`, resourceType: "agent", resourceId: target.id, context: { tool: "fn_agent_delete", params, approvalDedupeKey }, }, }); return { content: [{ type: "text" as const, text: `Approval required to delete agent ${target.name}. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id }, }; } if (policy.decision === "deny") { await options?.runAuditor?.database({ type: "agent:delete:denied", target: target.id, metadata: { policy } }); return { content: [{ type: "text" as const, text: `DENIED: agent delete blocked by policy (${policy.matchedRule})` }], details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id }, }; } try { await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text" as const, text: `ERROR: ${message}` }], details: {} }; } await options?.runAuditor?.database({ type: "agent:delete:approved", target: target.id, metadata: { policy, autoApproved: true } }); return { content: [{ type: "text" as const, text: `Deleted agent ${target.name} (${target.id})` }], details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id }, }; }, }; } export async function executeApprovedAgentProvisioning( approvalRequest: { id: string; status: string; targetAction: { resourceId: string } } & Parameters[0], deps: { agentStore: AgentStore }, ): Promise<{ deletedId: string } | Awaited>> { if (approvalRequest.status !== "approved") { throw new Error(`Approval request ${approvalRequest.id} must be approved before provisioning execution`); } const { tool, params } = extractAgentProvisioningRequest(approvalRequest); if (tool === "fn_agent_create") { const runtimeConfig: Record = { ...(typeof params.heartbeat_interval_ms === "number" ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}), ...(typeof params.heartbeat_timeout_ms === "number" ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}), ...(typeof params.max_concurrent_runs === "number" ? { maxConcurrentRuns: params.max_concurrent_runs } : {}), ...(typeof params.message_response_mode === "string" ? { messageResponseMode: params.message_response_mode } : {}), }; return deps.agentStore.createAgent({ name: String(params.name), role: String(params.role) as never, ...(typeof params.soul === "string" ? { soul: params.soul } : {}), ...(typeof params.instructions_text === "string" ? { instructionsText: params.instructions_text } : {}), ...(typeof params.instructions_path === "string" ? { instructionsPath: params.instructions_path } : {}), reportsTo: typeof params.reportsTo === "string" ? params.reportsTo : undefined, ...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}), }); } await deps.agentStore.deleteAgent(approvalRequest.targetAction.resourceId, { force: params.force === true, reassignTo: typeof params.reassign_to === "string" ? params.reassign_to : undefined, }); return { deletedId: approvalRequest.targetAction.resourceId }; } export function createDelegateTaskTool( agentStore: AgentStore, taskStore: TaskStore, options?: AgentTaskCreationOptions, ): ToolDefinition { return { name: "fn_delegate_task", label: "Delegate Task", description: "Create a new task and assign it to a specific agent for execution. The task goes to " + "'todo' and will be picked up by the target agent on their next heartbeat cycle. " + "Use fn_list_agents first to find available agents and their capabilities.", parameters: delegateTaskParams, execute: async (_id: string, params: Static) => { // Validate target agent exists const agent = await agentStore.getAgent(params.agent_id); if (!agent) { return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {}, }; } // Validate target agent is not ephemeral if (isEphemeralAgent(agent)) { return { content: [{ type: "text" as const, text: `ERROR: Cannot delegate to ephemeral/runtime agent ${params.agent_id}` }], details: {}, }; } const override = params.override === true; const newTaskRef = { id: "", column: "todo" } as const; if (!override && !canAgentTakeImplementationTaskForExplicitRouting(agent, { column: newTaskRef.column })) { return { content: [{ type: "text" as const, text: `ERROR: ${formatRoleMismatchReason(agent, newTaskRef)}` }], details: {}, }; } try { // Create task assigned to the target agent const task = await createAgentTask(taskStore, { description: params.description, dependencies: params.dependencies, column: "todo", assignedAgentId: params.agent_id, source: { sourceType: "api", ...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}), }, }, options); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; return { content: [{ type: "text" as const, text: `Delegated to ${agent.name} (${agent.id}): Created ${task.id}${deps}. ` + `The task will be picked up by ${agent.name} on their next heartbeat cycle.`, }], details: { taskId: task.id, agentId: agent.id, agentName: agent.name }, }; } catch (err) { if (err instanceof Error && err.message.startsWith("Task ID already exists:")) { return { content: [{ type: "text" as const, text: `ERROR: ${err.message}` }], details: {}, isError: true, }; } throw err; } }, }; } /** * Create a `fn_send_message` tool that sends a message to another agent or user. * * @param messageStore - MessageStore for message persistence * @param fromAgentId - The agent ID sending the message * @returns ToolDefinition for the `fn_send_message` tool */ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: string): ToolDefinition { return { name: "fn_send_message", label: "Send Message", description: "Send a message to another agent or user. The recipient will be woken if they have " + "`messageResponseMode: 'immediate'` configured. When replying to an existing message, " + "include `reply_to_message_id` to preserve threading.", parameters: sendMessageParams, // eslint-disable-next-line @typescript-eslint/no-explicit-any execute: async (_id: string, params: Static, _signal?: any, _onUpdate?: any, _ctx?: any) => { // Validate content length const content = params.content.trim(); if (content.length === 0) { return { content: [{ type: "text" as const, text: "ERROR: Message content cannot be empty" }], details: {}, }; } if (content.length > 2000) { return { content: [{ type: "text" as const, text: "ERROR: Message content exceeds 2000 character limit" }], details: {}, }; } try { const inferredDashboardRecipient = normalizeMessageParticipant(params.to_id, "user"); const messageType = params.type ?? (inferredDashboardRecipient.id === DASHBOARD_USER_ID ? "agent-to-user" : "agent-to-agent"); const recipientType: "user" | "agent" = messageType === "agent-to-user" ? "user" : "agent"; const recipient = recipientType === "user" ? normalizeMessageParticipant(params.to_id, recipientType) : { id: params.to_id, type: recipientType }; const replyToMessageId = params.reply_to_message_id?.trim(); if (params.reply_to_message_id !== undefined && !replyToMessageId) { return { content: [{ type: "text" as const, text: "ERROR: reply_to_message_id must be a non-empty string" }], details: {}, }; } const message = messageStore.sendMessage({ fromId: fromAgentId, fromType: "agent", toId: recipient.id, toType: recipient.type, content, type: messageType, ...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}), }); return { content: [{ type: "text" as const, text: `Message sent to ${recipient.id === DASHBOARD_USER_ID ? DASHBOARD_USER_ID : params.to_id} (ID: ${message.id})`, }], details: { messageId: message.id }, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: `ERROR: Failed to send message: ${errorMessage}` }], details: {}, }; } }, }; } /** * Create a `fn_read_messages` tool that reads inbox messages for an agent. * * @param messageStore - MessageStore for message retrieval * @param agentId - The agent ID whose inbox to read * @returns ToolDefinition for the `fn_read_messages` tool */ type ResearchToolsOptions = { store: TaskStore; rootDir: string; getSettings: () => Promise; }; function formatResearchRunDetails(run: ResearchRun) { const findings = run.results?.findings ?? []; const citations = run.results?.citations ?? []; return { runId: run.id, status: run.status, query: run.query, summary: run.results?.summary ?? null, findings, citations, sourceCount: run.sources.length, error: run.error ?? null, setup: null as null | { code: string; message: string }, }; } function researchUnavailable(code: string, message: string) { return { content: [{ type: "text" as const, text: message }], details: { runId: null, status: "unavailable", summary: null, findings: [], citations: [], error: message, setup: { code, message }, }, }; } export function createResearchTools(options: ResearchToolsOptions): ToolDefinition[] { const orchestratorState: { orchestrator: ResearchOrchestrator | null; providerRegistry: ResearchProviderRegistry | null; inFlight: Map>; } = { orchestrator: null, providerRegistry: null, inFlight: new Map(), }; const ensureOrchestrator = async (): Promise => { const settings = await options.getSettings(); const resolved = resolveResearchSettings(settings); if (!resolved.enabled) { return null; } if (!orchestratorState.providerRegistry) { orchestratorState.providerRegistry = new ResearchProviderRegistry(settings, options.rootDir); } else { orchestratorState.providerRegistry.refreshSettings(settings); } const registry = orchestratorState.providerRegistry; const availableProviders = registry.getAvailableProviders(); if (availableProviders.length === 0) { return null; } if (!orchestratorState.orchestrator) { const stepRunner = new ResearchStepRunner({ providers: availableProviders .map((type) => registry.getProvider(type)) .filter((provider): provider is NonNullable => Boolean(provider)), }); orchestratorState.orchestrator = new ResearchOrchestrator({ store: options.store.getResearchStore(), stepRunner, maxConcurrentRuns: resolved.limits.maxConcurrentRuns, }); } return orchestratorState.orchestrator; }; const runTool: ToolDefinition = { name: "fn_research_run", label: "Run Research", description: "Start a bounded research run and optionally wait for completion to get findings.", parameters: researchRunParams, execute: async (_id: string, params: Static) => { const settings = await options.getSettings(); const resolved = resolveResearchSettings(settings); if (!resolved.enabled) { return researchUnavailable("feature-disabled", "Research is disabled in settings. Enable researchSettings.enabled or global research defaults first."); } const orchestrator = await ensureOrchestrator(); if (!orchestrator) { return researchUnavailable("provider-unavailable", "Research providers are not configured. Add provider credentials in Settings → Authentication and select a research provider."); } const registry = orchestratorState.providerRegistry; const availableProviderTypes = registry?.getAvailableProviders() ?? []; const runId = orchestrator.createRun({ providers: availableProviderTypes .filter((type) => type !== "llm-synthesis") .map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })), maxSources: resolved.limits.maxSourcesPerRun, maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2), phaseTimeoutMs: resolved.limits.maxDurationMs, stepTimeoutMs: resolved.limits.requestTimeoutMs, }); const runPromise = orchestrator.startRun(runId, params.query); orchestratorState.inFlight.set(runId, runPromise.then(() => undefined).catch(() => undefined)); void runPromise.finally(() => orchestratorState.inFlight.delete(runId)); if (!params.wait_for_completion) { const started = options.store.getResearchStore().getRun(runId); if (!started) { return { content: [{ type: "text" as const, text: `Started research run ${runId} for: ${params.query}` }], details: { runId, status: "pending", summary: null, findings: [], citations: [], sourceCount: 0, error: null, setup: null }, }; } return { content: [{ type: "text" as const, text: `Started research run ${runId} for: ${params.query}` }], details: formatResearchRunDetails(started), }; } const maxWaitMs = Math.max(1_000, Math.min(params.max_wait_ms ?? 90_000, resolved.limits.maxDurationMs)); const completed = await Promise.race([ runPromise, new Promise((resolve) => setTimeout(() => { const latest = options.store.getResearchStore().getRun(runId); resolve(latest ?? ({ id: runId, query: params.query, status: "running", sources: [], events: [], tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as ResearchRun)); }, maxWaitMs)), ]); const details = formatResearchRunDetails(completed); const text = details.status === "completed" ? `Research run ${runId} completed. ${details.summary ?? "No summary generated."}` : `Research run ${runId} is ${details.status}. Use fn_research_get for updates.`; return { content: [{ type: "text" as const, text }], details }; }, }; const listTool: ToolDefinition = { name: "fn_research_list", label: "List Research Runs", description: "List recent research runs with status and summary snippets.", parameters: researchListParams, execute: async (_id: string, params: Static) => { const limit = Math.max(1, Math.min(params.limit ?? 10, 50)); const runs = options.store.getResearchStore().listRuns({ status: params.status as ResearchRunStatus | undefined, limit, }); const text = runs.length ? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n") : "No research runs found."; return { content: [{ type: "text" as const, text }], details: { runs: runs.map((run) => formatResearchRunDetails(run)) }, }; }, }; const getTool: ToolDefinition = { name: "fn_research_get", label: "Get Research Run", description: "Get one research run with structured findings and citations.", parameters: researchGetParams, execute: async (_id: string, params: Static) => { const run = options.store.getResearchStore().getRun(params.id); if (!run) { return { content: [{ type: "text" as const, text: `Research run ${params.id} not found.` }], details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null }, }; } const details = formatResearchRunDetails(run); return { content: [{ type: "text" as const, text: `Research run ${run.id} is ${run.status}.` }], details, }; }, }; const cancelTool: ToolDefinition = { name: "fn_research_cancel", label: "Cancel Research Run", description: "Cancel an active research run.", parameters: researchCancelParams, execute: async (_id: string, params: Static) => { const orchestrator = await ensureOrchestrator(); if (!orchestrator) { return researchUnavailable("provider-unavailable", "Research orchestrator is unavailable because research providers are not configured."); } const cancelled = orchestrator.cancelRun(params.id); const run = options.store.getResearchStore().getRun(params.id); if (!run) { return { content: [{ type: "text" as const, text: `Research run ${params.id} not found.` }], details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null }, }; } return { content: [{ type: "text" as const, text: cancelled ? `Cancellation requested for ${params.id}.` : `Run ${params.id} is not active.` }], details: formatResearchRunDetails(run), }; }, }; return [runTool, listTool, getTool, cancelTool]; } export function createPostRoomMessageTool(chatStore: ChatStore, fromAgentId: string): ToolDefinition { return { name: "fn_post_room_message", label: "Post Room Message", description: "Post a message to a room you are a member of. Room membership is enforced before posting, " + "so only reply when the room content is relevant to your role or identity.", parameters: postRoomMessageParams, execute: async (_id: string, params: Static) => { const content = params.content.trim(); if (content.length === 0) { return { content: [{ type: "text" as const, text: "ERROR: Message content cannot be empty" }], details: {}, }; } if (content.length > 2000) { return { content: [{ type: "text" as const, text: "ERROR: Message content exceeds 2000 character limit" }], details: {}, }; } const replyToMessageId = params.replyToMessageId?.trim(); if (params.replyToMessageId !== undefined && !replyToMessageId) { return { content: [{ type: "text" as const, text: "ERROR: replyToMessageId must be a non-empty string" }], details: {}, }; } try { const isMember = chatStore.listRoomMembers(params.roomId).some((member) => member.agentId === fromAgentId); if (!isMember) { return { content: [{ type: "text" as const, text: `ERROR: Agent ${fromAgentId} is not a member of room ${params.roomId}` }], details: {}, isError: true, }; } const message = chatStore.addRoomMessage(params.roomId, { role: "assistant", senderAgentId: fromAgentId, content, mentions: params.mentions ?? [], ...(replyToMessageId ? { metadata: { replyToMessageId } } : {}), }); return { content: [{ type: "text" as const, text: `Room message posted to ${params.roomId} (ID: ${message.id})` }], details: { messageId: message.id }, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: `ERROR: Failed to post room message: ${errorMessage}` }], details: {}, isError: true, }; } }, }; } export function createReadMessagesTool(messageStore: MessageStore, agentId: string): ToolDefinition { const REPLY_CONTEXT_CONTENT_MAX_CHARS = 400; const trimReplyContent = (value: string): string => { if (value.length <= REPLY_CONTEXT_CONTENT_MAX_CHARS) { return value; } return `${value.slice(0, REPLY_CONTEXT_CONTENT_MAX_CHARS - 1)}…`; }; const resolveReplyContext = (msg: Message): { parentMessageId: string; parentMessage: Message | null; missingParent: boolean; } | null => { const metadata = msg.metadata; const parentMessageId = typeof metadata === "object" && metadata !== null && "replyTo" in metadata && typeof metadata.replyTo === "object" && metadata.replyTo !== null && "messageId" in metadata.replyTo && typeof metadata.replyTo.messageId === "string" ? metadata.replyTo.messageId : null; if (!parentMessageId) { return null; } const parentMessage = messageStore.getMessage(parentMessageId); return { parentMessageId, parentMessage, missingParent: !parentMessage, }; }; return { name: "fn_read_messages", label: "Read Messages", description: "Read your inbox messages. Returns unread messages by default.", parameters: readMessagesParams, // eslint-disable-next-line @typescript-eslint/no-explicit-any execute: async (_id: string, params: Static, _signal?: any, _onUpdate?: any, _ctx?: any) => { const unreadOnly = params.unread_only ?? true; const limit = params.limit ?? 20; try { const filter = { ...(unreadOnly ? { read: false as const } : {}), limit, }; const messages = messageStore.getInbox(agentId, "agent", filter); if (messages.length === 0) { return { content: [{ type: "text" as const, text: "No messages" }], details: {}, }; } const messageEntries = messages.map((msg: Message) => { const replyContext = resolveReplyContext(msg); return { message: msg, replyContext, }; }); const lines = messageEntries.map(({ message, replyContext }) => { const timestamp = new Date(message.createdAt).toLocaleString(); const readStatus = message.read ? "[read] " : "[unread] "; const baseLine = `${readStatus}[id: ${message.id}] [from: ${message.fromType}:${message.fromId}] ${message.content} (${timestamp})`; if (!replyContext) { return baseLine; } if (replyContext.parentMessage) { const parent = replyContext.parentMessage; return `${baseLine}\n ↳ reply-to [id: ${parent.id}] [from: ${parent.fromType}:${parent.fromId}] ${trimReplyContent(parent.content)}`; } return `${baseLine}\n ↳ reply-to [id: ${replyContext.parentMessageId}] (missing parent message)`; }); return { content: [{ type: "text" as const, text: `Messages (${messages.length}):\n${lines.join("\n")}`, }], details: { messages, threadContext: messageEntries .filter((entry) => entry.replyContext) .map((entry) => ({ messageId: entry.message.id, replyTo: entry.replyContext, })), }, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: `ERROR: Failed to read messages: ${errorMessage}` }], details: {}, }; } }, }; }