/** * 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 { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core"; import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags } from "@fusion/core"; import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, 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 "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/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"; import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js"; import { recordRetry } from "./retry-burned-logger.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 workflowListParams = Type.Object({}); export const workflowGetParams = Type.Object({ workflow_id: Type.String({ description: "The workflow definition ID to fetch (e.g. 'WF-003', or a 'builtin:*' id). " + "Use fn_workflow_list to discover available IDs.", }), }); export const workflowSelectParams = Type.Object({ workflow_id: Type.String({ description: "The workflow definition ID to select (e.g. 'WF-003', or a 'builtin:*' id). " + "Use fn_workflow_list to discover available IDs.", }), task_id: Type.Optional( Type.String({ description: "Task to assign the workflow to. Defaults to the current task." }), ), }); export const taskPromoteParams = Type.Object({ task_id: Type.Optional( Type.String({ description: "Held task to promote. Defaults to the current task." }), ), }); export const workflowCreateParams = Type.Object({ name: Type.String({ description: "Workflow name (required, non-empty)." }), description: Type.Optional(Type.String({ description: "Optional human-readable description." })), ir: Type.Unknown({ description: "Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected.", }), layout: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: "Optional node layout map keyed by node id.", }), ), confirm_policy_escalation: Type.Optional( Type.Boolean({ description: "Set true to confirm binding a column to an agent whose permission policy is broader " + "(more privileged) than the project default. Required when such a binding is present; " + "the create is otherwise rejected naming the offending column.", }), ), }); export const workflowUpdateParams = Type.Object({ workflow_id: Type.String({ description: "The workflow definition ID to update (built-ins cannot be edited)." }), name: Type.Optional(Type.String({ description: "New name." })), description: Type.Optional(Type.String({ description: "New description." })), ir: Type.Optional(Type.Unknown({ description: "Replacement workflow graph (validated server-side)." })), layout: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Replacement node layout map." })), rehome_to: Type.Optional( Type.String({ description: "When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. " + "Required to resolve an OccupiedColumns conflict; the target must exist in the new IR.", }), ), confirm_policy_escalation: Type.Optional( Type.Boolean({ description: "Set true to confirm binding a column to an agent whose permission policy is broader " + "(more privileged) than the project default. Required when such a binding is present; " + "the update is otherwise rejected naming the offending column.", }), ), }); export const workflowDeleteParams = Type.Object({ workflow_id: Type.String({ description: "The workflow definition ID to delete (built-ins cannot be deleted)." }), }); export const traitListParams = Type.Object({}); 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; bypassDuplicateCheck?: boolean; acknowledgedDuplicates?: string[]; }; export async function createAgentTask( store: TaskStore, input: TaskCreateInput, options?: AgentTaskCreationOptions, ): Promise<{ task: Awaited>; wasDuplicate: boolean }> { const settings = typeof (store as { getSettings?: unknown }).getSettings === "function" ? await store.getSettings() : {} as Settings; const rootDir = options?.rootDir; const guard = await runDeterministicDuplicateGuard(store, { title: input.title, description: input.description, }, { lockScope: rootDir ?? store.getRootDir?.() ?? "agent-tools", bypass: options?.bypassDuplicateCheck === true, acknowledgedDuplicates: options?.acknowledgedDuplicates, logger: log, }); try { if (guard.action === "duplicate" && guard.existing) { return { task: guard.existing, wasDuplicate: true }; } const sourceMetadata = { ...(input.source?.sourceMetadata ?? {}), ...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}), }; const nextSource = input.source ? { ...input.source, sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined, } : undefined; const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.()) ?? {}; const resolvedTracking = resolveTaskGithubTracking( { githubTracking: input.githubTracking }, settings, globalSettings, ); const shouldPrefillGithubTrackingEnabled = input.githubTracking?.enabled !== false && resolvedTracking.enabled; const createInput: TaskCreateInput = { ...input, source: nextSource, githubTracking: shouldPrefillGithubTrackingEnabled ? { ...(input.githubTracking ?? {}), enabled: true, ...(input.githubTracking?.repoOverride || !resolvedTracking.repo ? {} : { repoOverride: `${resolvedTracking.repo.owner}/${resolvedTracking.repo.repo}` }), } : input.githubTracking, }; const createdTask = await store.createTask(createInput, { settings: { autoSummarizeTitles: settings.autoSummarizeTitles === true }, onSummarize: rootDir ? async (description: string) => { const resolved = resolveTitleSummarizerSettingsModel(settings); return summarizeTitle(description, rootDir, resolved.provider, resolved.modelId); } : undefined, }); const reconcile = await reconcileDeterministicDuplicate(store, { createdTask, fingerprint: guard.fingerprint, logger: log, }); return { task: reconcile.canonical, wasDuplicate: reconcile.outcome === "archived" }; } finally { guard.releaseLock(); } } /** * 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; sourceParentTaskId?: 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. " + "Before creating, scan existing open tasks for similar work — if an open task " + "already covers this, do not create a duplicate. " + "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, wasDuplicate } = 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, sourceParentTaskId: provenance.sourceParentTaskId, } : undefined, }, options); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; return { content: [{ type: "text" as const, text: `${wasDuplicate ? "Linked existing" : "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, }; } if (err instanceof Error && (err as { code?: string }).code === "SELF_DEFEATING_DEPENDENCY") { 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: {}, }; } }, }; } /** * Create a `fn_workflow_list` tool that lists the workflows available for a * project (read-only built-ins plus user-authored definitions). Agent-native * parity with the dashboard's workflow picker. */ export function createWorkflowListTool(store: TaskStore): ToolDefinition { return { name: "fn_workflow_list", label: "List Workflows", description: "List the custom workflows available for this project — read-only built-ins " + "(ids starting with 'builtin:') and user-authored definitions. Use before " + "fn_workflow_select to discover valid workflow IDs.", parameters: workflowListParams, execute: async () => { try { const workflows = await store.listWorkflowDefinitions(); if (workflows.length === 0) { return { content: [{ type: "text" as const, text: "No workflows are defined for this project." }], details: {}, }; } const lines = workflows.map( (w) => `- ${w.id}: ${w.name}${w.description ? ` — ${w.description}` : ""}`, ); return { content: [{ type: "text" as const, text: `Available workflows:\n${lines.join("\n")}` }], details: { workflowIds: workflows.map((w) => w.id) }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to list workflows: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_workflow_get` tool that returns a single workflow definition by * id — id/name/description, whether it is a read-only built-in, and the full * resolved IR (nodes/edges/columns/artifacts/fields) as JSON. Agent-native * read parity with the dashboard's workflow inspector; the companion read tool * to fn_workflow_list. Read-only; an unknown id is reported as a tool error. */ export function createWorkflowGetTool(store: TaskStore): ToolDefinition { return { name: "fn_workflow_get", label: "Get Workflow", description: "Fetch a single workflow definition by its ID — its name, description, whether it is a " + "read-only built-in, and its full IR (nodes, edges, columns, artifacts, and custom fields) " + "as JSON. Use fn_workflow_list to discover IDs first.", parameters: workflowGetParams, execute: async (_id: string, params: Static) => { const workflowId = params.workflow_id?.trim(); if (!workflowId) { return { content: [{ type: "text" as const, text: "ERROR: workflow_id is required." }], details: {}, isError: true, }; } try { const def = await store.getWorkflowDefinition(workflowId); if (!def) { return { content: [{ type: "text" as const, text: `ERROR: Unknown workflow id '${workflowId}'. Use fn_workflow_list to discover valid IDs.` }], details: {}, isError: true, }; } const builtin = isBuiltinWorkflowId(def.id); const payload = { id: def.id, name: def.name, description: def.description, builtin, ir: def.ir, // Preserve editor node positions so a read→modify→write cycle does not // strip the layout. May be absent for older/built-in defs; only include // when present to keep the payload tidy. ...(def.layout ? { layout: def.layout } : {}), }; return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], details: { workflowId: def.id, builtin, ...(def.layout ? { layout: def.layout } : {}) }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to get workflow: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_workflow_select` tool that assigns a workflow definition to a * task (defaulting to the current task). Mirrors the dashboard's per-task * workflow selection so an agent can set up a task the same way a user can. */ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string): ToolDefinition { return { name: "fn_workflow_select", label: "Select Workflow", description: "Assign a custom workflow to a task by its workflow ID. Defaults to the " + "current task when task_id is omitted. Note: selecting a workflow does not " + "retroactively change a pipeline already running — it applies when the task " + "next executes its steps. Use fn_workflow_list to find valid IDs.", parameters: workflowSelectParams, execute: async (_id: string, params: Static) => { const taskId = params.task_id?.trim() || currentTaskId; try { const { enabledWorkflowSteps: enabled, reconciliation } = await store.selectTaskWorkflowAndReconcile(taskId, params.workflow_id); const stepSummary = `${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled`; // Surface the reconciliation outcome so the agent observes any re-home: // a preserved card stays put; an unpreserved card moves fromColumn→toColumn. const rehomeNote = reconciliation && !reconciliation.preserved && reconciliation.fromColumn !== reconciliation.toColumn ? ` Re-homed from '${reconciliation.fromColumn}' to '${reconciliation.toColumn}'.` : reconciliation ? ` Card preserved in '${reconciliation.toColumn}'.` : ""; return { content: [{ type: "text" as const, text: `Selected workflow ${params.workflow_id} for ${taskId} (${stepSummary}).${rehomeNote}`, }], details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled, reconciliation }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to select workflow: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_task_promote` tool that manually releases a held task out of its * hold column — the agent-native equivalent of the dashboard's "promote" action. * Defaults to the current task. Wraps {@link promoteHeldTask}. */ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): ToolDefinition { return { name: "fn_task_promote", label: "Promote Held Task", description: "Manually promote a held task out of its hold column, releasing it regardless of the " + "hold's release kind (the explicit operator action a 'manual' hold waits for). Defaults " + "to the current task. Returns the destination column, or a rejection reason when the task " + "is not held or the destination is full.", parameters: taskPromoteParams, execute: async (_id: string, params: Static) => { const taskId = params.task_id?.trim() || currentTaskId; try { const outcome = await promoteHeldTask(store, taskId); if (outcome.released) { return { content: [{ type: "text" as const, text: `Promoted ${taskId} to column '${outcome.toColumn}'.`, }], details: { taskId, released: true, toColumn: outcome.toColumn }, }; } return { content: [{ type: "text" as const, text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.`, }], details: { taskId, released: false, rejection: outcome.rejection }, isError: true, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to promote task: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13). * Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so * an agent cannot persist a binding the UI would reject (existence + * policy-escalation). Constructs a per-call AgentStore from the store's fusion * dir (the connection is process-cached) and feeds it the project settings. * * A {@link ColumnAgentBindingError} propagates unchanged; each tool's catch * surfaces its message (which names the column and, for an escalation, instructs * passing `confirm_policy_escalation: true`). */ async function assertWorkflowColumnAgentBindings( store: TaskStore, ir: unknown, confirmPolicyEscalation: boolean, ): Promise { const columns = (ir as { columns?: unknown })?.columns; if (!Array.isArray(columns) || !columns.some((c) => c?.agent?.agentId)) return; const agentStore = new AgentStore({ rootDir: store.getFusionDir() }); await agentStore.init(); const settings = await store.getSettings(); await validateColumnAgentBindings({ ir, agentStore, settings, confirmPolicyEscalation }); } /** * Render a {@link ColumnAgentBindingError} as a structured tool error result. * Re-phrases the escalation guidance with the tool's snake_case flag name * (`confirm_policy_escalation`) rather than the route's camelCase variant. */ function columnAgentBindingErrorResult(err: ColumnAgentBindingError) { const text = err.reason === "policy-escalation" ? `Column '${err.columnId}' binds agent '${err.agentId}' whose permission policy is broader than ` + `the project default; pass confirm_policy_escalation: true to confirm.` : err.message; return { content: [{ type: "text" as const, text: `ERROR: ${text}` }], details: { columnId: err.columnId, agentId: err.agentId, reason: err.reason }, isError: true as const, }; } /** * Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow * definition create. The IR is validated server-side; a malformed graph rejects. */ export function createWorkflowCreateTool( store: TaskStore, opts?: WorkflowAuthoringToolOptions, ): ToolDefinition { return { name: "fn_workflow_create", label: "Create Workflow", description: "Create a new custom workflow definition from a name and a workflow graph (IR). " + "The IR is validated server-side; a malformed graph rejects. Returns the new workflow ID.\n" + "v2 IR supports step-inversion constructs (all additive, opt-in): " + "`parse-steps` node {artifact, parser} writes the task step list from a declared artifact " + "(built-in parsers: `step-headings`, `json-steps`; routable `no-steps`/`parse-error` outcomes) — " + "it must precede any `foreach`; " + "`foreach` node {source:'task-steps', template:{nodes,edges}, mode:'sequential'|'parallel', " + "isolation:'shared'|'worktree', concurrency (parallel only, 1-8), maxReworkCycles (1-10)} " + "instantiates its single-entry/exit template subgraph once per planned step " + "(parallel+shared is rejected); a `step-execute` node is legal only inside a foreach template; " + "`step-review` node {type:'plan'|'code', model?} surfaces verdicts as outcome edges " + "(`outcome:approve|revise|rethink|unavailable`); edges may set `kind:'rework'` (the only legal cycles, " + "back to step-execute within an instance; rethink edges trigger a reset-to-baseline); " + "`code` node {source, timeoutMs?} runs sandboxed TypeScript returning {outcome?, contextPatch?, customFields?}. " + "Declare task documents via `artifacts: [{key, title?, producedBy?, role?}]` and custom task fields via " + "`fields: [{id, name, type, required?, default?, options?, render?}]` (types: string/text/number/boolean/" + "enum/multi-enum/date/url; render.placement card|detail|detail-section, render.badge for card chips).\n" + "Bind a column to a permanent agent via `columns[].agent: { agentId, mode }`: `mode:'defer'` applies the " + "column agent only when the work carries no own agent/model settings, while `mode:'override'` supersedes " + "node/task settings wholesale. The bound agent must exist; if its permission policy is broader than the " + "project default, pass `confirm_policy_escalation: true` to confirm (the create is otherwise rejected).", parameters: workflowCreateParams, execute: async (_id: string, params: Static) => { try { let approvalNote = ""; // eslint-disable-next-line @typescript-eslint/no-explicit-any let ir = params.ir as any; if (opts?.stripApprovalFlags) { const result = stripApprovalBypassFlags(ir); ir = result.ir; if (result.stripped) approvalNote = " (approval-bypass flags removed)"; } await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true); const created = await store.createWorkflowDefinition({ name: params.name, description: params.description, ir, // eslint-disable-next-line @typescript-eslint/no-explicit-any layout: params.layout as any, }); return { content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).${approvalNote}` }], details: { workflowId: created.id, name: created.name }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (err instanceof ColumnAgentBindingError) { return columnAgentBindingErrorResult(err); } return { content: [{ type: "text" as const, text: `ERROR: Failed to create workflow: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_workflow_update` tool — a thin wrapper over the store's workflow * definition update. When an IR change removes a still-occupied column, the store * throws an OccupiedColumnsError; we surface it as a structured response carrying * the per-column occupant counts so the agent can retry with `rehome_to`. */ export function createWorkflowUpdateTool( store: TaskStore, opts?: WorkflowAuthoringToolOptions, ): ToolDefinition { return { name: "fn_workflow_update", label: "Update Workflow", description: "Update a custom workflow definition (name/description/ir/layout). Built-ins cannot be edited. " + "If an IR change removes a column that still holds cards, the update is blocked and returns the " + "occupied columns — retry with rehome_to set to a column id that survives in the new IR. " + "The IR accepts the same step-inversion constructs as fn_workflow_create (foreach with mode/isolation/" + "concurrency, step-execute, step-review, parse-steps, code nodes, rework edges, artifacts, fields). " + "Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields.\n" + "Bind a column to a permanent agent via `columns[].agent: { agentId, mode }`: `mode:'defer'` applies the " + "column agent only when the work carries no own agent/model settings, while `mode:'override'` supersedes " + "node/task settings wholesale. The bound agent must exist; if its permission policy is broader than the " + "project default, pass `confirm_policy_escalation: true` to confirm (the update is otherwise rejected).", parameters: workflowUpdateParams, execute: async (_id: string, params: Static) => { try { let approvalNote = ""; // eslint-disable-next-line @typescript-eslint/no-explicit-any let ir = params.ir as any; if (ir !== undefined && ir !== null) { if (opts?.stripApprovalFlags) { const result = stripApprovalBypassFlags(ir); ir = result.ir; if (result.stripped) approvalNote = " (approval-bypass flags removed)"; } await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true); } const updated = await store.updateWorkflowDefinition(params.workflow_id, { name: params.name, description: params.description, ir, // eslint-disable-next-line @typescript-eslint/no-explicit-any layout: params.layout as any, rehomeTo: params.rehome_to, }); return { content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).${approvalNote}` }], details: { workflowId: updated.id, name: updated.name }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (err instanceof ColumnAgentBindingError) { return columnAgentBindingErrorResult(err); } // Surface the typed OccupiedColumnsError as a structured, retryable result. if (err?.name === "OccupiedColumnsError") { const occupancies = err.occupancies ?? []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", "); return { content: [{ type: "text" as const, text: `ERROR: Update removes occupied column(s): ${summary}. ` + `Retry with rehome_to set to a surviving column id.`, }], details: { occupiedColumns: occupancies, workflowId: err.workflowId, retryWith: "rehome_to" }, isError: true, }; } return { content: [{ type: "text" as const, text: `ERROR: Failed to update workflow: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_workflow_delete` tool — a thin wrapper over the store's workflow * definition delete. Surfaces built-in protection and not-found errors as * structured responses. (The store auto-re-homes occupants to the default * workflow on delete, so no rehome target is required here.) */ export function createWorkflowDeleteTool(store: TaskStore): ToolDefinition { return { name: "fn_workflow_delete", label: "Delete Workflow", description: "Delete a custom workflow definition. Built-ins cannot be deleted. Any tasks using it have " + "their selection cleared and are re-homed to the default workflow's entry column.", parameters: workflowDeleteParams, execute: async (_id: string, params: Static) => { try { await store.deleteWorkflowDefinition(params.workflow_id); return { content: [{ type: "text" as const, text: `Deleted workflow ${params.workflow_id}.` }], details: { workflowId: params.workflow_id }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (err?.name === "OccupiedColumnsError") { const occupancies = err.occupancies ?? []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", "); return { content: [{ type: "text" as const, text: `ERROR: Delete blocked by occupied column(s): ${summary}.`, }], details: { occupiedColumns: occupancies, workflowId: err.workflowId }, isError: true, }; } return { content: [{ type: "text" as const, text: `ERROR: Failed to delete workflow: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Create a `fn_trait_list` tool that returns the trait catalog from * {@link listTraits} — the column-behavior building blocks (id, name, flags) * used when authoring workflow columns. */ export function createTraitListTool(): ToolDefinition { return { name: "fn_trait_list", label: "List Traits", description: "List the available column traits (the behavior building blocks for workflow columns): " + "id, name, description, and behavior flags. Use when authoring or updating a workflow IR.", parameters: traitListParams, execute: async () => { try { const traits = listTraits(); if (traits.length === 0) { return { content: [{ type: "text" as const, text: "No traits are registered." }], details: { traits: [] }, }; } const lines = traits.map( (t) => `- ${t.id}: ${t.name}${t.description ? ` — ${t.description}` : ""}`, ); return { content: [{ type: "text" as const, text: `Available traits:\n${lines.join("\n")}` }], details: { traits: traits.map((t) => ({ id: t.id, name: t.name, description: t.description, flags: t.flags })), }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to list traits: ${err?.message ?? err}` }], details: {}, isError: true, }; } }, }; } /** * Assemble the full workflow-authoring tool surface for a single store-scoped * lane (chat / planning / executor): the six `fn_workflow_*` tools plus * `fn_trait_list` (trait vocabulary needed to author/update workflow IR). * * Centralizing the list keeps the chat and planning lanes from drifting away * from the executor's set as new workflow tools are added. `currentTaskId` is * the default task for `fn_workflow_select`; lanes with no ambient task pass a * placeholder (an agent can still target any task via the `task_id` param). */ /** * Options for the workflow authoring tool set. * * `stripApprovalFlags` removes the `cliSkipApproval`/`autoApprove` approval- * bypass flags from every node config (incl. nested foreach templates) before * the create/update tools persist the IR. Chat and planning lanes are prompt- * injectable, so they pass `true`; the executor lane (project-owner escape * hatch) omits it so the dashboard editor can deliberately set those flags. */ export interface WorkflowAuthoringToolOptions { stripApprovalFlags?: boolean; } export function createWorkflowAuthoringTools( store: TaskStore, currentTaskId: string, opts?: WorkflowAuthoringToolOptions, ): ToolDefinition[] { return [ createWorkflowListTool(store), createWorkflowGetTool(store), createWorkflowSelectTool(store, currentTaskId), createWorkflowCreateTool(store, opts), createWorkflowUpdateTool(store, opts), createWorkflowDeleteTool(store), createTraitListTool(), ]; } 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, wasDuplicate } = 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}): ${wasDuplicate ? "Linked existing" : "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, options?: { autoRecovery?: ProjectSettings["autoRecovery"]; runAudit?: RunAuditor; taskStore?: TaskStore; settings?: Settings }, ): ToolDefinition { const deliveryHandler = new MessageDeliveryAutoRecoveryHandler({ runAudit: options?.runAudit ?? { database: async () => {}, git: async () => {}, filesystem: async () => {}, sandbox: async () => {} }, }); 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) => { 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 result = await deliveryHandler.runWithBoundedRetry({ run: async () => Promise.resolve(messageStore.sendMessage({ fromId: fromAgentId, fromType: "agent", toId: recipient.id, toType: recipient.type, content, type: messageType, ...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}), })), correlation: { kind: "direct", fromAgentId, toId: recipient.id }, }, options?.autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 }, async () => { const taskId = _ctx?.taskId as string | undefined; if (!taskId || !options?.taskStore || !options.settings) { return; } const task = await options.taskStore.getTask(taskId); if (!task) { return; } await recordRetry({ store: options.taskStore, settings: options.settings, task, category: "messageDelivery", role: "executor", agentId: fromAgentId }); }); if (result.outcome === "parked") { return { content: [{ type: "text" as const, text: `ERROR: Failed to send message: ${result.error.message}` }], details: {}, }; } return { content: [{ type: "text" as const, text: `Message sent to ${recipient.id === DASHBOARD_USER_ID ? DASHBOARD_USER_ID : params.to_id} (ID: ${result.value.id})`, }], details: { messageId: result.value.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, options?: { autoRecovery?: ProjectSettings["autoRecovery"]; runAudit?: RunAuditor; taskStore?: TaskStore; settings?: Settings }, ): ToolDefinition { const deliveryHandler = new MessageDeliveryAutoRecoveryHandler({ runAudit: options?.runAudit ?? { database: async () => {}, git: async () => {}, filesystem: async () => {}, sandbox: async () => {} }, }); 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, // eslint-disable-next-line @typescript-eslint/no-explicit-any execute: async (_id: string, params: Static, _signal?: any, _onUpdate?: any, _ctx?: any) => { 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 result = await deliveryHandler.runWithBoundedRetry({ run: async () => Promise.resolve(chatStore.addRoomMessage(params.roomId, { role: "assistant", senderAgentId: fromAgentId, content, mentions: params.mentions ?? [], ...(replyToMessageId ? { metadata: { replyToMessageId } } : {}), })), correlation: { kind: "room", fromAgentId, roomId: params.roomId }, }, options?.autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 }, async () => { const taskId = _ctx?.taskId as string | undefined; if (!taskId || !options?.taskStore || !options.settings) { return; } const task = await options.taskStore.getTask(taskId); if (!task) { return; } await recordRetry({ store: options.taskStore, settings: options.settings, task, category: "messageDelivery", role: "executor", agentId: fromAgentId }); }); if (result.outcome === "parked") { return { content: [{ type: "text" as const, text: `ERROR: Failed to post room message: ${result.error.message}` }], details: {}, isError: true, }; } return { content: [{ type: "text" as const, text: `Room message posted to ${params.roomId} (ID: ${result.value.id})` }], details: { messageId: result.value.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: {}, }; } }, }; }