Resolved 4 conflicts preserving both feature sets: - WorkflowNodeEditor.tsx: kept card-node/edge/dialog/dirty-guard/auto-layout/ onboarding/template/AI-edit features; wired main's columnAgentsEnabled flag gate, override-column agent registry load, and policy-escalation save retry (finishSave helper wraps both update payloads). - WorkflowNodeEditor.test.tsx: kept U2/U4/AI-design describes plus main's U6 column-agent describe; merged api mock + import lists. - register-workflow-routes.ts: merged import sets (design DI seam + column-agent validators). - agent-tools.ts: strip-approval-flags and column-agent binding assertion now both run in create/update tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3046 lines
123 KiB
TypeScript
3046 lines
123 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, 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<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$/;
|
|
|
|
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<string> {
|
|
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<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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
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<MemorySearchHit[]> {
|
|
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>) => {
|
|
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<ReturnType<TaskStore["createTask"]>>; 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<typeof taskCreateParams>) => {
|
|
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<typeof taskLogParams>) => {
|
|
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<typeof taskLogParams>) => {
|
|
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<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: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<typeof workflowGetParams>) => {
|
|
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<typeof workflowSelectParams>) => {
|
|
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<typeof taskPromoteParams>) => {
|
|
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<void> {
|
|
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<typeof workflowCreateParams>) => {
|
|
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<typeof workflowUpdateParams>) => {
|
|
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<typeof workflowDeleteParams>) => {
|
|
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<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 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<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 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<typeof webFetchParams>) => {
|
|
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<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
|
|
*/
|
|
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<typeof readEvaluationsParams>) => {
|
|
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<typeof updateIdentityParams>) => {
|
|
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<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 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<typeof getAgentConfigParams>) => {
|
|
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<string, unknown>;
|
|
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<typeof updateAgentConfigParams>) => {
|
|
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<string, unknown>),
|
|
...(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<string, unknown>;
|
|
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<ProjectSettings | undefined>;
|
|
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<typeof createAgentParams>) => {
|
|
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<string, unknown> = {
|
|
...(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<typeof deleteAgentParams>) => {
|
|
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<typeof extractAgentProvisioningRequest>[0],
|
|
deps: { agentStore: AgentStore },
|
|
): Promise<{ deletedId: string } | Awaited<ReturnType<AgentStore["createAgent"]>>> {
|
|
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<string, unknown> = {
|
|
...(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<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: {},
|
|
};
|
|
}
|
|
|
|
const override = params.override === true;
|
|
const newTaskRef = { id: "<new>", 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<typeof sendMessageParams>, _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<Settings>;
|
|
};
|
|
|
|
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<string, Promise<void>>;
|
|
} = {
|
|
orchestrator: null,
|
|
providerRegistry: null,
|
|
inFlight: new Map(),
|
|
};
|
|
|
|
const ensureOrchestrator = async (): Promise<ResearchOrchestrator | null> => {
|
|
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<typeof provider> => 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<typeof researchRunParams>) => {
|
|
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<ResearchRun>((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<typeof researchListParams>) => {
|
|
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<typeof researchGetParams>) => {
|
|
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<typeof researchCancelParams>) => {
|
|
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<typeof postRoomMessageParams>, _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<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 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: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|