- Update Fusion skill docs, prompts, and capability references to use public fn_* tool names consistently - Align engine system prompts and tool schemas for messaging/task actions with fn_send_message, fn_read_messages, fn_task_* naming - Refresh related tests across CLI, engine, dashboard, and core to match normalized tool naming and behavior - Add a patch changeset for @runfusion/fusion describing the skill-tool namespace normalization
1046 lines
40 KiB
TypeScript
1046 lines
40 KiB
TypeScript
/**
|
|
* 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 } from "node:path";
|
|
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core";
|
|
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
|
import type { AgentReflectionService } from "./agent-reflection.js";
|
|
import { createLogger } from "./logger.js";
|
|
|
|
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
|
|
|
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\"])" }),
|
|
),
|
|
});
|
|
|
|
export const taskLogParams = Type.Object({
|
|
message: Type.String({ description: "What happened" }),
|
|
outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })),
|
|
});
|
|
|
|
export const taskDocumentWriteParams = Type.Object({
|
|
key: Type.String({
|
|
description: "Document key (e.g., 'plan', 'notes', 'research'). Alphanumeric, hyphens, underscores, 1-64 chars.",
|
|
}),
|
|
content: Type.String({ description: "Document content to store" }),
|
|
author: Type.Optional(Type.String({ description: "Who is writing (default: 'agent')" })),
|
|
});
|
|
|
|
export const taskDocumentReadParams = Type.Object({
|
|
key: Type.Optional(
|
|
Type.String({ description: "Document key to read. Omit to list all documents for this task." }),
|
|
),
|
|
});
|
|
|
|
export const reflectOnPerformanceParams = Type.Object({
|
|
focus_area: Type.Optional(
|
|
Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }),
|
|
),
|
|
});
|
|
|
|
export const 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\"])" }),
|
|
),
|
|
});
|
|
|
|
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 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 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 AGENT_MEMORY_ROOT = ".fusion/agent-memory";
|
|
const AGENT_MEMORY_FILENAME = "MEMORY.md";
|
|
const AGENT_DREAMS_FILENAME = "DREAMS.md";
|
|
const agentQmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
|
const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
|
|
|
function sanitizeAgentMemoryId(agentId: string): string {
|
|
return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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 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<string | null> {
|
|
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<!-- Per-agent memory. Keep separate from workspace Project Memory. -->\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<!-- Synthesized patterns from this agent's daily notes. -->\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<!-- Running observations for this agent. -->\n`, "utf-8");
|
|
}
|
|
return agentMemoryDisplayPath(agentMemory.agentId);
|
|
}
|
|
|
|
async function listAgentMemoryFiles(rootDir: string, agentMemory: AgentMemoryContext): Promise<Array<{ absPath: string; displayPath: string }>> {
|
|
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<MemorySearchHit[]> {
|
|
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<void> {
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise<MemorySearchHit[]> {
|
|
if (!agentMemory.memory?.trim()) {
|
|
return [];
|
|
}
|
|
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<string, unknown>) => ({
|
|
path: agentMemoryDisplayPath(agentMemory.agentId),
|
|
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 ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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): ToolDefinition {
|
|
return {
|
|
name: "fn_task_create",
|
|
label: "Create Task",
|
|
description:
|
|
"Create a new task for out-of-scope work discovered during execution. " +
|
|
"The task goes into triage where it will be specified by the AI. " +
|
|
"Optionally set dependencies (e.g., the new task depends on the current one, " +
|
|
"or the current task should wait for the new one).",
|
|
parameters: taskCreateParams,
|
|
execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
|
|
const task = await store.createTask({
|
|
description: params.description,
|
|
dependencies: params.dependencies,
|
|
column: "triage",
|
|
});
|
|
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Created ${task.id}: ${params.description}${deps}`,
|
|
}],
|
|
details: { taskId: task.id },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<typeof taskLogParams>) => {
|
|
await store.logEntry(taskId, params.message, params.outcome);
|
|
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<typeof taskLogParams>) => {
|
|
await store.logEntry(taskId, params.message, params.outcome, runContext);
|
|
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<typeof taskDocumentWriteParams>) => {
|
|
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<typeof taskDocumentReadParams>) => {
|
|
try {
|
|
if (params.key) {
|
|
const document: TaskDocument | null = await store.getTaskDocument(taskId, params.key);
|
|
if (!document) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `Document "${params.key}" not found.` }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text:
|
|
`Document: ${document.key}\n` +
|
|
`Revision: ${document.revision}\n` +
|
|
`Updated: ${document.updatedAt}\n\n` +
|
|
document.content,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
const documents: TaskDocument[] = await store.getTaskDocuments(taskId);
|
|
if (documents.length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "No documents found for this task." }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
const lines = documents.map((doc) => `- ${doc.key} (revision ${doc.revision}, updated ${doc.updatedAt})`);
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Task documents:\n${lines.join("\n")}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Failed to read task documents: ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
|
|
return {
|
|
name: "fn_memory_search",
|
|
label: "Search Memory",
|
|
description:
|
|
"Search durable project memory and this agent's own memory, returning small snippets with file paths and line ranges. " +
|
|
"Use this before fn_memory_get; do not read all memory by default.",
|
|
parameters: memorySearchParams,
|
|
execute: async (_id: string, params: Static<typeof memorySearchParams>) => {
|
|
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<typeof memoryGetParams>) => {
|
|
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 project memory. Use long-term only for durable conventions/decisions/pitfalls; " +
|
|
"use daily for running observations and open loops. Skip this tool when there is no reusable memory.",
|
|
parameters: memoryAppendParams,
|
|
execute: async (_id: string, params: Static<typeof memoryAppendParams>) => {
|
|
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 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<typeof reflectOnPerformanceParams>) => {
|
|
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
|
|
*/
|
|
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<typeof listAgentsParams>) => {
|
|
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 createDelegateTaskTool(agentStore: AgentStore, taskStore: TaskStore): 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<typeof delegateTaskParams>) => {
|
|
// 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: {},
|
|
};
|
|
}
|
|
|
|
// Create task assigned to the target agent
|
|
const task = await taskStore.createTask({
|
|
description: params.description,
|
|
dependencies: params.dependencies,
|
|
column: "todo",
|
|
assignedAgentId: params.agent_id,
|
|
});
|
|
|
|
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Delegated to ${agent.name} (${agent.id}): Created ${task.id}${deps}. ` +
|
|
`The task will be picked up by ${agent.name} on their next heartbeat cycle.`,
|
|
}],
|
|
details: { taskId: task.id, agentId: agent.id, agentName: agent.name },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a `fn_send_message` tool that sends a message to another agent or user.
|
|
*
|
|
* @param messageStore - MessageStore for message persistence
|
|
* @param fromAgentId - The agent ID sending the message
|
|
* @returns ToolDefinition for the `fn_send_message` tool
|
|
*/
|
|
export function createSendMessageTool(messageStore: MessageStore, fromAgentId: string): ToolDefinition {
|
|
return {
|
|
name: "fn_send_message",
|
|
label: "Send Message",
|
|
description:
|
|
"Send a message to another agent or user. The recipient will be woken if they have " +
|
|
"`messageResponseMode: 'immediate'` configured. When replying to an existing message, " +
|
|
"include `reply_to_message_id` to preserve threading.",
|
|
parameters: sendMessageParams,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
execute: async (_id: string, params: Static<typeof sendMessageParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
|
|
// Validate content length
|
|
const content = params.content.trim();
|
|
if (content.length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "ERROR: Message content cannot be empty" }],
|
|
details: {},
|
|
};
|
|
}
|
|
if (content.length > 2000) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "ERROR: Message content exceeds 2000 character limit" }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
try {
|
|
const messageType = params.type ?? "agent-to-agent";
|
|
const recipientType = messageType === "agent-to-user" ? "user" : "agent";
|
|
const replyToMessageId = params.reply_to_message_id?.trim();
|
|
|
|
if (params.reply_to_message_id !== undefined && !replyToMessageId) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "ERROR: reply_to_message_id must be a non-empty string" }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
const message = messageStore.sendMessage({
|
|
fromId: fromAgentId,
|
|
fromType: "agent",
|
|
toId: params.to_id,
|
|
toType: recipientType,
|
|
content,
|
|
type: messageType,
|
|
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
|
|
});
|
|
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Message sent to ${params.to_id} (ID: ${message.id})`,
|
|
}],
|
|
details: { messageId: message.id },
|
|
};
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Failed to send message: ${errorMessage}` }],
|
|
details: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a `fn_read_messages` tool that reads inbox messages for an agent.
|
|
*
|
|
* @param messageStore - MessageStore for message retrieval
|
|
* @param agentId - The agent ID whose inbox to read
|
|
* @returns ToolDefinition for the `fn_read_messages` tool
|
|
*/
|
|
export function createReadMessagesTool(messageStore: MessageStore, agentId: string): ToolDefinition {
|
|
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<typeof readMessagesParams>, _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 lines = messages.map((msg: Message) => {
|
|
const timestamp = new Date(msg.createdAt).toLocaleString();
|
|
const readStatus = msg.read ? "[read] " : "[unread] ";
|
|
return `${readStatus}[id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
|
|
});
|
|
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Messages (${messages.length}):\n${lines.join("\n")}`,
|
|
}],
|
|
details: { messages },
|
|
};
|
|
} 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: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|