Agents that must edit files beyond a task's declared ## File Scope had no
way to keep the scope in sync, so those edits were stranded at merge (the
squash merge is scoped to ## File Scope, and cross-task overlap blocking +
the merge file-scope invariant both read it).
New executor tool fn_task_file_scope_add validates repo-relative
paths/globs with isValidFileScopeEntry, de-dupes against existing scope,
appends them to the ## File Scope section of PROMPT.md, and persists via
store.updateTask({ prompt }) (same validation + task.json/PROMPT.md sync as
fn_task_prompt_write). Registered in the main coding-agent tool list; the
base executor prompt now instructs the agent to call it when editing beyond
the declared scope. Merge-time peer-claim refusal is unchanged and remains
the cross-task backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4393 lines
182 KiB
TypeScript
4393 lines
182 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 * as fusionCore from "@fusion/core";
|
|
import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
|
|
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon } 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, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } 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 { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js";
|
|
import { recordRetry } from "./retry-burned-logger.js";
|
|
import { acquireWorkspaceRepoWorktree, WorkspaceRepoAcquireBusyError } from "./worktree-acquisition.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)",
|
|
}),
|
|
),
|
|
workflow_id: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
|
|
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
|
|
}),
|
|
),
|
|
});
|
|
|
|
export const taskLogParams = Type.Object({
|
|
message: Type.String({ description: "What happened" }),
|
|
outcome: Type.Optional(Type.String({ description: "Result or consequence (optional)" })),
|
|
});
|
|
|
|
export const taskListParams = Type.Object({});
|
|
|
|
export const taskShowParams = Type.Object({
|
|
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
|
});
|
|
|
|
export const taskSearchParams = Type.Object({
|
|
query: Type.String({ minLength: 1, description: "Search query" }),
|
|
includeDone: Type.Optional(Type.Boolean({ description: "Include done tasks (default true)" })),
|
|
includeArchived: Type.Optional(Type.Boolean({ description: "Include archived tasks (default true)" })),
|
|
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: "Max results (default 20, max 50)" })),
|
|
});
|
|
|
|
export const acquireRepoWorktreeParams = Type.Object({
|
|
repo: Type.String({
|
|
description:
|
|
"Relative path of the sub-repo within the workspace to acquire a worktree in " +
|
|
"(e.g. 'wolf-server'). Must be one of the repos listed in the workspace. " +
|
|
"If already acquired, returns the existing worktree path immediately.",
|
|
}),
|
|
});
|
|
|
|
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 taskPromptWriteParams = Type.Object({
|
|
content: Type.String({ description: "Complete replacement content for this task's PROMPT.md." }),
|
|
});
|
|
|
|
export const taskFileScopeAddParams = Type.Object({
|
|
files: Type.Array(
|
|
Type.String({
|
|
description:
|
|
"A repo-relative file path or glob to add to the task's File Scope, e.g. `packages/engine/src/foo.ts` or `packages/dashboard/app/**`. No leading slash, no `..`, no URLs or git refs.",
|
|
}),
|
|
{ minItems: 1, description: "One or more files/globs to add to this task's ## File Scope." },
|
|
),
|
|
reason: Type.Optional(
|
|
Type.String({ description: "Short reason these files must be edited (recorded in the agent log)." }),
|
|
),
|
|
});
|
|
|
|
export const chatTaskDocumentWriteParams = Type.Object({
|
|
task_id: Type.String({ description: "Task ID to write the document to (e.g. 'FN-001')." }),
|
|
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 chatTaskDocumentReadParams = Type.Object({
|
|
task_id: Type.String({ description: "Task ID to read documents from (e.g. 'FN-001')." }),
|
|
key: Type.Optional(
|
|
Type.String({ description: "Document key to read. Omit to list all documents for this task." }),
|
|
),
|
|
});
|
|
|
|
const ARTIFACT_TYPE_VALUES = ["document", "image", "video", "audio", "other"] as const;
|
|
const artifactTypeSchema = Type.Union(ARTIFACT_TYPE_VALUES.map((type) => Type.Literal(type)), {
|
|
description: "Artifact type: document, image, video, audio, or other.",
|
|
});
|
|
|
|
export const artifactRegisterParams = Type.Object({
|
|
type: artifactTypeSchema,
|
|
title: Type.String({ description: "Human-readable artifact title." }),
|
|
description: Type.Optional(Type.String({ description: "Optional longer artifact description or caption." })),
|
|
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
|
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
|
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
|
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
|
taskId: Type.Optional(Type.String({ description: "Optional associated task ID (e.g. 'FN-001')." })),
|
|
});
|
|
|
|
export const artifactListParams = Type.Object({
|
|
type: Type.Optional(artifactTypeSchema),
|
|
authorId: Type.Optional(Type.String({ description: "Filter by registering author/agent ID." })),
|
|
taskId: Type.Optional(Type.String({ description: "Filter by associated task ID." })),
|
|
search: Type.Optional(Type.String({ description: "Search artifact titles, descriptions, content, and task metadata." })),
|
|
limit: Type.Optional(Type.Number({ description: "Maximum number of artifacts to return." })),
|
|
offset: Type.Optional(Type.Number({ description: "Number of artifacts to skip." })),
|
|
});
|
|
|
|
export const artifactViewParams = Type.Object({
|
|
id: Type.String({ description: "Artifact ID to view." }),
|
|
});
|
|
|
|
export const chatArtifactRegisterParams = Type.Object({
|
|
type: artifactTypeSchema,
|
|
title: Type.String({ description: "Human-readable artifact title." }),
|
|
description: Type.Optional(Type.String({ description: "Optional longer artifact description or caption." })),
|
|
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
|
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
|
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
|
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
|
task_id: Type.String({ description: "Associated task ID (e.g. 'FN-001')." }),
|
|
});
|
|
|
|
export const chatArtifactListParams = Type.Object({
|
|
type: Type.Optional(artifactTypeSchema),
|
|
authorId: Type.Optional(Type.String({ description: "Filter by registering author/agent ID." })),
|
|
task_id: Type.String({ description: "Associated task ID to list artifacts for." }),
|
|
search: Type.Optional(Type.String({ description: "Search artifact titles, descriptions, content, and task metadata." })),
|
|
limit: Type.Optional(Type.Number({ description: "Maximum number of artifacts to return." })),
|
|
offset: Type.Optional(Type.Number({ description: "Number of artifacts to skip." })),
|
|
});
|
|
|
|
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." })),
|
|
icon: Type.Optional(Type.String({ description: "Optional compact plain-text icon for this custom workflow." })),
|
|
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." })),
|
|
icon: Type.Optional(Type.String({ description: "New compact plain-text icon; blank clears it." })),
|
|
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 workflowSettingsParams = Type.Object({
|
|
action: Type.Union([Type.Literal("get"), Type.Literal("set")], {
|
|
description:
|
|
"`get` reads the stored setting VALUES plus the engine-effective values for the workflow; " +
|
|
"`set` writes values (requires `values`).",
|
|
}),
|
|
workflow_id: Type.String({
|
|
description:
|
|
"The workflow whose setting VALUES to read/write (e.g. 'WF-003', or a 'builtin:*' id). " +
|
|
"Values are scoped per (workflow, project). Built-in workflow VALUES are writable even though " +
|
|
"built-in DECLARATIONS are not (declarations are edited via the workflow IR's `settings`). " +
|
|
"Values are validated against THIS workflow's declared settings (use fn_workflow_get to inspect them).",
|
|
}),
|
|
values: Type.Optional(
|
|
Type.Record(Type.String(), Type.Unknown(), {
|
|
description:
|
|
"For action='set': a map of settingId → value to write. A `null` value DELETES the override " +
|
|
"(null-as-delete). Each value is validated against the named workflow's declaration; on ANY " +
|
|
"rejection (unknown-setting/type-mismatch/enum-violation/no-settings-defined) nothing is " +
|
|
"persisted and the typed rejection list is returned.",
|
|
}),
|
|
),
|
|
});
|
|
|
|
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 goalListParams = Type.Object({
|
|
status: Type.Optional(
|
|
Type.Union([
|
|
Type.Literal("active"),
|
|
Type.Literal("archived"),
|
|
Type.Literal("all"),
|
|
], { description: "Filter by goal status (default: active)" }),
|
|
),
|
|
});
|
|
|
|
export const goalShowParams = Type.Object({
|
|
id: Type.String({ description: "Goal ID (G-…)" }),
|
|
});
|
|
|
|
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\"])" }),
|
|
),
|
|
workflow_id: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). " +
|
|
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
|
|
}),
|
|
),
|
|
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 askQuestionParams = Type.Object({
|
|
questions: Type.Array(
|
|
Type.Object({
|
|
question: Type.String({
|
|
description: "The question text shown to the user. Required and must be specific enough to answer.",
|
|
}),
|
|
header: Type.Optional(Type.String({
|
|
description: "Optional short heading for the question card, such as 'Decision needed'.",
|
|
})),
|
|
description: Type.Optional(Type.String({
|
|
description: "Optional helper text explaining why the answer is needed or how it will be used.",
|
|
})),
|
|
options: Type.Optional(Type.Array(Type.Object({
|
|
label: Type.String({ description: "Visible option label the user can choose." }),
|
|
description: Type.Optional(Type.String({ description: "Optional explanatory text for this option." })),
|
|
}), {
|
|
description: "Options for single_select, multi_select, or confirm questions. Select questions require at least one option.",
|
|
})),
|
|
multiSelect: Type.Optional(Type.Boolean({
|
|
description: "Set true when the user may choose multiple options. Prefer type='multi_select' for clarity.",
|
|
})),
|
|
type: Type.Optional(Type.Union([
|
|
Type.Literal("text"),
|
|
Type.Literal("single_select"),
|
|
Type.Literal("multi_select"),
|
|
Type.Literal("confirm"),
|
|
], {
|
|
description: "Question input type: free text, single option, multiple options, or yes/no confirmation.",
|
|
})),
|
|
}),
|
|
{ description: "One or more structured questions to present to the user." },
|
|
),
|
|
});
|
|
|
|
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[];
|
|
/*
|
|
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
|
Set true when fn_task_create is registered for an ephemeral/runtime task-worker session (executor-FN-XXXX). The tool then honors the project `ephemeralAgentsCanCreateTasks` toggle and rejects creation when it is disabled. Permanent-agent sessions leave this unset and are never gated.
|
|
*/
|
|
callerIsEphemeral?: boolean;
|
|
};
|
|
|
|
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,
|
|
summarize: !input.title?.trim() ? true : undefined,
|
|
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,
|
|
});
|
|
|
|
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 the selected-or-default
|
|
* workflow's resolved intake column.
|
|
*
|
|
* @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 enters the selected-or-default workflow's intake/planning column " +
|
|
"where it will be specified by the AI (a custom workflow with a non-triage " +
|
|
"intake column, e.g. Inbox, lands the card there instead and it stays inert " +
|
|
"until released). " +
|
|
"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). " +
|
|
"Optionally pass workflow_id to select a workflow at creation time; use " +
|
|
"fn_workflow_list to discover valid IDs.",
|
|
parameters: taskCreateParams,
|
|
execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
|
|
try {
|
|
/*
|
|
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
|
Ephemeral task-worker sessions may only create tasks when the project `ephemeralAgentsCanCreateTasks` toggle is on (default true). Fail-open on a settings read error so a store hiccup never blocks creation.
|
|
*/
|
|
if (options?.callerIsEphemeral) {
|
|
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
|
|
? await store.getSettings().catch(() => ({} as Settings))
|
|
: ({} as Settings);
|
|
if ((settings as Settings).ephemeralAgentsCanCreateTasks === false) {
|
|
const message =
|
|
"Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project).";
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
|
|
details: { error: message, rule: "ephemeral-agents-cannot-create-tasks" },
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
const workflowId = params.workflow_id?.trim() || undefined;
|
|
/*
|
|
FNXC:Workflows 2026-07-05-00:00:
|
|
fn_task_create must NOT hardcode column:"triage" here. TaskStore.createTask already
|
|
resolves the landing column from the selected-or-default workflow's intake-trait
|
|
column (input.column || resolvedEntryColumn || "triage" in _createTaskInternal); a
|
|
hardcoded override here defeated that resolution and made a non-triage intake column
|
|
(e.g. a custom workflow's "Inbox" hold column) dead configuration, since the card
|
|
always jumped straight into triage and started the Planner seam immediately.
|
|
Omitting `column` lets a custom workflow's Inbox-style intake column capture new
|
|
cards inert (no bootstrap spec generation) while the default builtin:coding workflow
|
|
still resolves to "triage" (byte-identical prior behavior).
|
|
*/
|
|
const { task, wasDuplicate } = await createAgentTask(store, {
|
|
description: params.description,
|
|
dependencies: params.dependencies,
|
|
priority: params.priority,
|
|
...(workflowId ? { workflowId } : {}),
|
|
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(", ")})` : "";
|
|
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `${wasDuplicate ? "Linked existing" : "Created"} ${task.id}: ${params.description}${deps}${workflow}`,
|
|
}],
|
|
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;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string;
|
|
type TaskListFormatter = (
|
|
lines: string[],
|
|
opts?: { maxChars?: number; clamp?: TaskListClamp },
|
|
) => string;
|
|
|
|
function inlineTaskReadListFallback(
|
|
lines: string[],
|
|
opts: { maxChars?: number } = {},
|
|
): string {
|
|
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS));
|
|
try {
|
|
const text = lines.join("\n");
|
|
if (text.length <= maxChars) {
|
|
return text;
|
|
}
|
|
return text.slice(0, Math.max(0, maxChars - 1)) + "…";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function resolveTaskReadListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter {
|
|
return typeof core.formatTaskListText === "function"
|
|
? (core.formatTaskListText as TaskListFormatter)
|
|
: inlineTaskReadListFallback;
|
|
}
|
|
|
|
function formatTaskReadLines(lines: string[], emptyStateText: string): string {
|
|
if (lines.length === 0) {
|
|
return emptyStateText;
|
|
}
|
|
const formatter = resolveTaskReadListFormatter(fusionCore);
|
|
const text = formatter(lines, { clamp: fusionCore.clampTaskListText });
|
|
return text.trim().length > 0 ? text : emptyStateText;
|
|
}
|
|
|
|
function formatTaskSummaryLine(task: { id: string; column: string; title?: string | null; description: string; dependencies: string[] }): string {
|
|
const desc = task.title || task.description.slice(0, 80) || "(no description)";
|
|
const deps = task.dependencies.length ? ` [deps: ${task.dependencies.join(", ")}]` : "";
|
|
return `${task.id} (${task.column}): ${desc}${deps}`;
|
|
}
|
|
|
|
/**
|
|
* FNXC:AgentTooling 2026-06-27-14:05:
|
|
* Shared read-only task discovery factories must return host-safe text and be reusable by triage, chat/planning, and heartbeat surfaces. Heartbeat agents now receive task read tools through this single store-backed implementation instead of bespoke copies.
|
|
*
|
|
* FNXC:AgentTooling 2026-06-27-00:00:
|
|
* Triage and planning-board surfaces now use canonical `fn_task_show`; deprecated `fn_task_get` survives only as a recognition alias in action-gate and analytics compatibility paths, not as a model-visible registered tool.
|
|
*/
|
|
export function createTaskListTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_task_list",
|
|
label: "List Tasks",
|
|
description:
|
|
"List active tasks that aren't done or archived. Returns ID, description, column, " +
|
|
"and dependencies for each. Use to discover work and check for duplicates.",
|
|
parameters: taskListParams,
|
|
execute: async () => {
|
|
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
|
const active = tasks.filter((task) => task.column !== "done");
|
|
const lines = active.map(formatTaskSummaryLine);
|
|
return {
|
|
content: [{ type: "text" as const, text: formatTaskReadLines(lines, "No active tasks.") }],
|
|
details: { count: active.length },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTaskSearchTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_task_search",
|
|
label: "Search Tasks",
|
|
description:
|
|
"Keyword search across tasks, including done and archived tasks by default. " +
|
|
"Use for duplicate detection and work discovery before filing new tasks.",
|
|
parameters: taskSearchParams,
|
|
execute: async (_id: string, params: Static<typeof taskSearchParams>) => {
|
|
const query = params.query.trim();
|
|
if (query.length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "No tasks matched." }],
|
|
details: { count: 0 },
|
|
};
|
|
}
|
|
const limit = Math.min(50, Math.max(1, Math.floor(params.limit ?? 20)));
|
|
const results = await store.searchTasks(query, {
|
|
slim: true,
|
|
includeArchived: params.includeArchived ?? true,
|
|
limit,
|
|
});
|
|
const includeDone = params.includeDone ?? true;
|
|
const filtered = includeDone ? results : results.filter((task) => task.column !== "done");
|
|
const lines = filtered.map(formatTaskSummaryLine);
|
|
const text = formatTaskReadLines(
|
|
lines.length > 0 ? [`Search results for "${query}" (${filtered.length}):`, ...lines] : [],
|
|
"No tasks matched.",
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text }],
|
|
details: { count: filtered.length },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTaskShowTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_task_show",
|
|
label: "Show Task",
|
|
description: "Show full details for a task including its PROMPT.md content.",
|
|
parameters: taskShowParams,
|
|
execute: async (_id: string, params: Static<typeof taskShowParams>) => {
|
|
try {
|
|
const task = await store.getTask(params.id);
|
|
const parts = [
|
|
`ID: ${task.id}`,
|
|
task.title ? `Title: ${task.title}` : null,
|
|
`Column: ${task.column}`,
|
|
`Status: ${task.status ?? task.column}`,
|
|
`Description: ${task.description || "(no description)"}`,
|
|
task.dependencies.length ? `Dependencies: ${task.dependencies.join(", ")}` : null,
|
|
Array.isArray(task.steps) && task.steps.length
|
|
? `Steps:\n${task.steps.map((step, index) => ` ${index}. ${step.name} — ${step.status}`).join("\n")}`
|
|
: null,
|
|
"",
|
|
"PROMPT.md:",
|
|
task.prompt || "(not yet specified)",
|
|
].filter((part): part is string => typeof part === "string");
|
|
return {
|
|
content: [{ type: "text" as const, text: parts.join("\n") || `Task ${params.id} has no details.` }],
|
|
details: { taskId: task.id },
|
|
};
|
|
} catch {
|
|
return {
|
|
content: [{ type: "text" as const, text: `Task ${params.id} not found.` }],
|
|
details: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTaskReadTools(store: TaskStore): ToolDefinition[] {
|
|
return [createTaskListTool(store), createTaskShowTool(store), createTaskSearchTool(store)];
|
|
}
|
|
|
|
/**
|
|
* 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>) => readTaskDocuments(store, taskId, params.key),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:WorkflowReviewers 2026-07-01-13:22:
|
|
* Plan Review inline fixes must be able to rewrite the task's authoritative PROMPT.md, but that pre-execution reviewer should not need general source-file write tools. Route the write through TaskStore so existing PROMPT.md validation, task directory placement, and task.json sync remain the single persistence path.
|
|
*/
|
|
export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runContext?: RunMutationContext): ToolDefinition {
|
|
return {
|
|
name: "fn_task_prompt_write",
|
|
label: "Write PROMPT.md",
|
|
description:
|
|
"Replace this task's PROMPT.md with revised plan/spec content. " +
|
|
"Use only during Plan Review/spec repair; provide the complete final PROMPT.md content.",
|
|
parameters: taskPromptWriteParams,
|
|
execute: async (_id: string, params: Static<typeof taskPromptWriteParams>) => {
|
|
try {
|
|
await store.updateTask(taskId, { prompt: params.content }, runContext);
|
|
return {
|
|
content: [{ type: "text" as const, text: `Updated PROMPT.md for ${taskId}.` }],
|
|
details: {},
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Failed to update PROMPT.md for ${taskId}: ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/*
|
|
FNXC:FileScope 2026-07-08-22:40:
|
|
Requirement: when an executing agent must edit files beyond the task's declared `## File Scope`, it should extend the declared scope itself rather than silently editing out-of-scope (which strands those edits — the merger's squash is scoped to `## File Scope`, and cross-task overlap blocking + the merge file-scope invariant both read it). This tool appends validated entries to the `## File Scope` section of PROMPT.md and persists via `store.updateTask({ prompt })`, so the same validation (`validateFileScopeInPromptContent`) and task.json/PROMPT.md sync path as `fn_task_prompt_write` applies, and `parseFileScopeFromPrompt` picks the additions up immediately.
|
|
Entries are validated with `isValidFileScopeEntry` and de-duplicated against existing scope. Marker-free plain `- \`path\`` lines are used (not the merger's `scopeAutoWiden` HTML-comment marker) so these read as first-class declared scope. Caveat: unlike the merge-time auto-widen, this does NOT re-run the peer-claim refusal (files owned by another active task's scope) — the merge-time invariant remains the backstop for genuine cross-task conflicts.
|
|
*/
|
|
export function createTaskFileScopeAddTool(store: TaskStore, taskId: string, runContext?: RunMutationContext): ToolDefinition {
|
|
return {
|
|
name: "fn_task_file_scope_add",
|
|
label: "Add to File Scope",
|
|
description:
|
|
"Add one or more files/globs to this task's declared ## File Scope when you need to edit beyond the initial scope. " +
|
|
"Use this instead of silently editing out-of-scope files so your changes are not stranded at merge. " +
|
|
"Paths are repo-relative (no leading slash, no `..`).",
|
|
parameters: taskFileScopeAddParams,
|
|
execute: async (_id: string, params: Static<typeof taskFileScopeAddParams>) => {
|
|
const errorContent = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
|
try {
|
|
const requested = params.files.map((f) => f.trim()).filter((f) => f.length > 0);
|
|
const rejected = requested.filter((f) => !fusionCore.isValidFileScopeEntry(f));
|
|
const valid = requested.filter((f) => fusionCore.isValidFileScopeEntry(f));
|
|
|
|
const task = await store.getTask(taskId);
|
|
const prompt = task.prompt ?? "";
|
|
const headingMatch = prompt.match(/^##\s+File Scope\s*$/m);
|
|
if (!headingMatch) {
|
|
return errorContent(
|
|
`ERROR: ${taskId}'s PROMPT.md has no "## File Scope" section to extend. Use fn_task_prompt_write if the spec needs a scope section.`,
|
|
);
|
|
}
|
|
|
|
const sectionStart = headingMatch.index! + headingMatch[0].length;
|
|
const rest = prompt.slice(sectionStart);
|
|
const nextHeadingIdx = rest.search(/^##\s/m);
|
|
const sectionEnd = nextHeadingIdx === -1 ? prompt.length : sectionStart + nextHeadingIdx;
|
|
const section = prompt.slice(sectionStart, sectionEnd);
|
|
|
|
const existing = new Set((section.match(/`([^`]+)`/g) ?? []).map((t) => t.slice(1, -1)));
|
|
const alreadyPresent = valid.filter((f) => existing.has(f));
|
|
const toAdd = valid.filter((f) => !existing.has(f));
|
|
|
|
if (toAdd.length === 0) {
|
|
const parts = ["No files added to File Scope."];
|
|
if (alreadyPresent.length > 0) parts.push(`Already present: ${alreadyPresent.join(", ")}.`);
|
|
if (rejected.length > 0) parts.push(`Rejected (invalid path/glob): ${rejected.join(", ")}.`);
|
|
return errorContent(parts.join(" "));
|
|
}
|
|
|
|
const insertion = toAdd.map((f) => `- \`${f}\``).join("\n");
|
|
const sectionTrimmed = section.replace(/\s+$/, "");
|
|
const newSection = sectionTrimmed.length === 0 ? `\n\n${insertion}\n` : `${sectionTrimmed}\n${insertion}\n`;
|
|
const newPrompt = prompt.slice(0, sectionStart) + newSection + prompt.slice(sectionEnd);
|
|
|
|
await store.updateTask(taskId, { prompt: newPrompt }, runContext);
|
|
await store
|
|
.appendAgentLog(
|
|
taskId,
|
|
`Added to File Scope: ${toAdd.join(", ")}${params.reason ? ` — ${params.reason}` : ""}`,
|
|
"text",
|
|
)
|
|
.catch(() => {});
|
|
|
|
const parts = [`Added to File Scope: ${toAdd.join(", ")}.`];
|
|
if (alreadyPresent.length > 0) parts.push(`Already present: ${alreadyPresent.join(", ")}.`);
|
|
if (rejected.length > 0) parts.push(`Rejected (invalid path/glob): ${rejected.join(", ")}.`);
|
|
return { content: [{ type: "text" as const, text: parts.join(" ") }], details: { added: toAdd } };
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return errorContent(`ERROR: Failed to update File Scope for ${taskId}: ${err.message}`);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatAgentTools 2026-06-18-06:51:
|
|
* Chat sessions do not have an ambient task, but users expect the same `fn_task_document_write` and `fn_task_document_read` names that task-bound lanes expose.
|
|
* Require an explicit `task_id` here, mirroring no-ambient workflow authoring tools, so FN-6635 chat agents can persist task documents without guessing a target task.
|
|
*/
|
|
export function createChatTaskDocumentTools(store: TaskStore): ToolDefinition[] {
|
|
return [
|
|
{
|
|
name: "fn_task_document_write",
|
|
label: "Write Document",
|
|
description:
|
|
"Save a named document for a task (for example plan, notes, or research). " +
|
|
"Each write creates a new revision so you can update documents over time. Requires task_id.",
|
|
parameters: chatTaskDocumentWriteParams,
|
|
execute: async (_id: string, params: Static<typeof chatTaskDocumentWriteParams>) => {
|
|
const input: TaskDocumentCreateInput = {
|
|
key: params.key,
|
|
content: params.content,
|
|
author: params.author || "agent",
|
|
};
|
|
|
|
try {
|
|
const document: TaskDocument = await store.upsertTaskDocument(params.task_id, 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}" for task ${params.task_id}: ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "fn_task_document_read",
|
|
label: "Read Document",
|
|
description:
|
|
"Read a named document for a task, or list all documents when no key is provided. Requires task_id.",
|
|
parameters: chatTaskDocumentReadParams,
|
|
execute: async (_id: string, params: Static<typeof chatTaskDocumentReadParams>) => (
|
|
readTaskDocuments(store, params.task_id, params.key)
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArtifactRegistry 2026-06-21-06:50:
|
|
* Agents need to register multi-type artifacts across agents and tasks while using the existing task store registry. A new artifact registration must also announce itself to the dashboard user's inbox, but that notification is best-effort and must never fail the artifact write.
|
|
*/
|
|
export function createArtifactRegisterTool(store: TaskStore, authorId: string, messageStore?: MessageStore): ToolDefinition {
|
|
return {
|
|
name: "fn_artifact_register",
|
|
label: "Register Artifact",
|
|
description:
|
|
"Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " +
|
|
"Provide inline content, a uri/path reference, or dataBase64 image bytes; optionally associate it with a taskId.",
|
|
parameters: artifactRegisterParams,
|
|
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArtifactRegistry 2026-06-21-06:50:
|
|
* Agents need a read-only cross-agent discovery surface for registered multi-type artifacts. Keep list rendering concise so agents can scan ids, media classes, authors, and task context before calling `fn_artifact_view`.
|
|
*/
|
|
export function createArtifactListTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_artifact_list",
|
|
label: "List Artifacts",
|
|
description:
|
|
"List registered artifacts across agents and tasks. Supports filters for type, authorId, taskId, search, limit, and offset.",
|
|
parameters: artifactListParams,
|
|
execute: async (_id: string, params: Static<typeof artifactListParams>) => listArtifactsForAgent(store, params),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArtifactRegistry 2026-06-21-06:50:
|
|
* Agents need to inspect artifact metadata plus inline content or URI references without relying on dashboard UI. Render binary media as references so tool output remains lightweight and safe for agent contexts.
|
|
*/
|
|
export function createArtifactViewTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_artifact_view",
|
|
label: "View Artifact",
|
|
description:
|
|
"View a registered artifact by id, including metadata and inline content when present or the uri/path reference for media artifacts.",
|
|
parameters: artifactViewParams,
|
|
execute: async (_id: string, params: Static<typeof artifactViewParams>) => viewArtifactForAgent(store, params.id),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArtifactRegistry 2026-06-21-06:50:
|
|
* Dashboard chat and planning lanes have no ambient task, so artifact tools require an explicit task target for register/list parity while keeping the canonical `fn_artifact_*` tool names available to agents.
|
|
*/
|
|
export function createChatArtifactTools(store: TaskStore, messageStore?: MessageStore): ToolDefinition[] {
|
|
const chatAuthorId = "dashboard-chat";
|
|
return [
|
|
{
|
|
name: "fn_artifact_register",
|
|
label: "Register Artifact",
|
|
description:
|
|
"Register an artifact for a specific task so other agents can discover it. Requires task_id, accepts dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
|
parameters: chatArtifactRegisterParams,
|
|
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
|
store,
|
|
chatAuthorId,
|
|
{
|
|
type: params.type,
|
|
title: params.title,
|
|
description: params.description,
|
|
mimeType: params.mimeType,
|
|
uri: params.uri,
|
|
content: params.content,
|
|
dataBase64: params.dataBase64,
|
|
taskId: params.task_id,
|
|
},
|
|
messageStore,
|
|
),
|
|
},
|
|
{
|
|
name: "fn_artifact_list",
|
|
label: "List Artifacts",
|
|
description:
|
|
"List registered artifacts for a specific task. Supports filters for type, authorId, search, limit, and offset. Requires task_id.",
|
|
parameters: chatArtifactListParams,
|
|
execute: async (_id: string, params: Static<typeof chatArtifactListParams>) => listArtifactsForAgent(store, {
|
|
type: params.type,
|
|
authorId: params.authorId,
|
|
taskId: params.task_id,
|
|
search: params.search,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
}),
|
|
},
|
|
createArtifactViewTool(store),
|
|
];
|
|
}
|
|
|
|
async function registerArtifactForAgent(
|
|
store: TaskStore,
|
|
authorId: string,
|
|
params: Static<typeof artifactRegisterParams>,
|
|
messageStore?: MessageStore,
|
|
) {
|
|
try {
|
|
const data = decodeArtifactDataBase64(params);
|
|
const input: ArtifactCreateInput = {
|
|
type: params.type,
|
|
title: params.title,
|
|
description: params.description,
|
|
mimeType: params.mimeType,
|
|
uri: params.uri,
|
|
content: params.content,
|
|
data,
|
|
authorId,
|
|
authorType: "agent",
|
|
taskId: params.taskId,
|
|
};
|
|
|
|
const artifact: Artifact = await store.registerArtifact(input);
|
|
notifyArtifactRegistered(messageStore, artifact, authorId);
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Registered artifact "${artifact.title}" (${artifact.type}) with id ${artifact.id}.`,
|
|
}],
|
|
details: { artifactId: artifact.id },
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Failed to register artifact "${params.title}": ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
|
* Agents need a portable way to create task-scoped image artifacts without reading arbitrary local files. `dataBase64` decodes inside the tool and then uses TaskStore's existing binary persistence path so registry rows continue to store only managed artifact URIs.
|
|
*
|
|
* FNXC:ArtifactRegistry 2026-06-29-17:05:
|
|
* `dataBase64` is an image-only payload source. Reject empty, non-image, and signature-mismatched bytes early so agents get actionable tool errors instead of persisting artifacts the dashboard cannot preview.
|
|
*/
|
|
function decodeArtifactDataBase64(params: Static<typeof artifactRegisterParams>): Buffer | undefined {
|
|
if (params.dataBase64 === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const encoded = params.dataBase64.trim();
|
|
if (encoded.length === 0) {
|
|
throw new Error("dataBase64 must decode to non-empty artifact bytes.");
|
|
}
|
|
|
|
if (params.uri || params.content) {
|
|
throw new Error("dataBase64 cannot be combined with uri or content; provide exactly one artifact payload source.");
|
|
}
|
|
|
|
if (params.type !== "image") {
|
|
throw new Error("dataBase64 is only supported for image artifacts; use uri or content for other types.");
|
|
}
|
|
|
|
const mimeType = params.mimeType?.toLowerCase().split(";", 1)[0];
|
|
if (!mimeType || !mimeType.startsWith("image/")) {
|
|
throw new Error("image artifacts registered with dataBase64 require an image/* mimeType such as image/png.");
|
|
}
|
|
|
|
const normalized = encoded.replace(/\s+/g, "");
|
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
|
throw new Error("dataBase64 must be valid base64-encoded artifact bytes.");
|
|
}
|
|
|
|
const data = Buffer.from(normalized, "base64");
|
|
|
|
if (!hasImageSignature(data, mimeType)) {
|
|
throw new Error(`dataBase64 must decode to valid image bytes matching mimeType ${mimeType}.`);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function hasImageSignature(data: Buffer, mimeType: string): boolean {
|
|
if (mimeType === "image/png") {
|
|
return data.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"));
|
|
}
|
|
|
|
if (mimeType === "image/jpeg") {
|
|
return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
|
}
|
|
|
|
if (mimeType === "image/gif") {
|
|
const header = data.subarray(0, 6).toString("ascii");
|
|
return header === "GIF87a" || header === "GIF89a";
|
|
}
|
|
|
|
if (mimeType === "image/webp") {
|
|
return data.length >= 12
|
|
&& data.subarray(0, 4).toString("ascii") === "RIFF"
|
|
&& data.subarray(8, 12).toString("ascii") === "WEBP";
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void {
|
|
if (!messageStore) return;
|
|
|
|
try {
|
|
messageStore.sendMessage({
|
|
fromType: "system",
|
|
toType: "user",
|
|
toId: DASHBOARD_USER_ID,
|
|
type: "system",
|
|
content: `New ${artifact.type} artifact registered: ${artifact.title}`,
|
|
metadata: {
|
|
artifactId: artifact.id,
|
|
artifactType: artifact.type,
|
|
title: artifact.title,
|
|
authorId,
|
|
taskId: artifact.taskId,
|
|
},
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
log.warn(`Failed to send best-effort artifact registration notification for ${artifact.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function listArtifactsForAgent(store: TaskStore, params: Static<typeof artifactListParams>) {
|
|
try {
|
|
const artifacts: ArtifactWithTask[] = await store.listArtifacts({
|
|
type: params.type,
|
|
authorId: params.authorId,
|
|
taskId: params.taskId,
|
|
search: params.search,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
});
|
|
|
|
if (artifacts.length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "No artifacts found." }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
const lines = artifacts.map((artifact) => {
|
|
const task = artifact.taskId ? `${artifact.taskId}${artifact.taskTitle ? ` (${artifact.taskTitle})` : ""}` : "no task";
|
|
return `- ${artifact.id} [${artifact.type}] ${artifact.title} — author: ${artifact.authorId}; task: ${task}`;
|
|
});
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Artifacts:\n${lines.join("\n")}`,
|
|
}],
|
|
details: { artifactIds: artifacts.map((artifact) => artifact.id) },
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Failed to list artifacts: ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
async function viewArtifactForAgent(store: TaskStore, id: string) {
|
|
try {
|
|
const artifact: Artifact | null = await store.getArtifact(id);
|
|
if (!artifact) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `Artifact "${id}" not found.` }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
const lines = [
|
|
`Artifact: ${artifact.title}`,
|
|
`ID: ${artifact.id}`,
|
|
`Type: ${artifact.type}`,
|
|
`Author: ${artifact.authorId} (${artifact.authorType})`,
|
|
`Created: ${artifact.createdAt}`,
|
|
`Updated: ${artifact.updatedAt}`,
|
|
];
|
|
if (artifact.taskId) lines.push(`Task: ${artifact.taskId}`);
|
|
if (artifact.description) lines.push(`Description: ${artifact.description}`);
|
|
if (artifact.mimeType) lines.push(`MIME type: ${artifact.mimeType}`);
|
|
if (typeof artifact.sizeBytes === "number") lines.push(`Size: ${artifact.sizeBytes} bytes`);
|
|
if (artifact.uri) lines.push(`URI: ${artifact.uri}`);
|
|
if (artifact.content) lines.push("", artifact.content);
|
|
|
|
return {
|
|
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
details: { artifactId: artifact.id },
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Failed to view artifact "${id}": ${err.message}`,
|
|
}],
|
|
details: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readTaskDocuments(store: TaskStore, taskId: string, key?: string) {
|
|
try {
|
|
if (key) {
|
|
const document: TaskDocument | null = await store.getTaskDocument(taskId, key);
|
|
if (!document) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `Document "${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 for task ${taskId}: ${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" +
|
|
"Declare typed workflow SETTINGS via `settings: [{id, name, type, default?, options?, description?, render?}]` " +
|
|
"(types: string/text/number/boolean/enum/multi-enum; settings have no card/detail placement — widget only). " +
|
|
"Settings carry workflow-scoped policy (step timeouts, review gates, model lanes); their per-project VALUES are " +
|
|
"read/written via fn_workflow_settings, not here.\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,
|
|
icon: normalizeWorkflowIcon(params.icon),
|
|
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, settings). " +
|
|
"Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields. " +
|
|
"Editing `settings` declarations changes the schema; orphaned setting VALUES are dropped on resolution " +
|
|
"(the engine never sees a value that no longer validates). Built-in workflow declarations cannot be edited; " +
|
|
"their per-project VALUES are written via fn_workflow_settings.\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,
|
|
...(params.icon !== undefined ? { icon: normalizeWorkflowIcon(params.icon) ?? null } : {}),
|
|
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_workflow_settings` tool — read/write the per-`(workflow, project)`
|
|
* setting VALUES that tune a workflow's policy (step timeouts, review gates, model
|
|
* lanes). This is the agent-native parity with the editor's workflow settings
|
|
* panel and mirrors the two-path contract from U2:
|
|
*
|
|
* - DECLARATIONS (the typed schema) live in the workflow IR's `settings` array and
|
|
* are authored via fn_workflow_create/update; built-in declarations are not
|
|
* editable (the store's built-in guard rejects an IR edit).
|
|
* - VALUES are written here against the NAMED workflow's declarations via
|
|
* {@link store.updateWorkflowSettingValues}. Built-in workflow VALUES are
|
|
* writable (per-project tuning of `builtin:coding`). An invalid value surfaces
|
|
* the typed rejection list ({@link WorkflowSettingRejectionError}) and persists
|
|
* nothing — the same contract HTTP/dashboard writers see.
|
|
*
|
|
* `action: "get"` returns both the raw `stored` values and the engine `effective`
|
|
* values (post drop-on-orphan), so an agent sees what it wrote and what the engine
|
|
* will actually consume.
|
|
*/
|
|
/**
|
|
* Resolve the setting DECLARATIONS for a workflow. Mirrors the store's private
|
|
* `resolveWorkflowSettingDeclarations` (and the dashboard route helper of the
|
|
* same shape): the resolved IR's `settings` when present, else the built-in
|
|
* catalog for built-in ids. Used to compute the `orphaned` value entries.
|
|
*/
|
|
async function resolveWorkflowSettingDeclarationsForTool(
|
|
store: TaskStore,
|
|
workflowId: string,
|
|
): Promise<WorkflowSettingDefinition[] | undefined> {
|
|
const ir = await resolveWorkflowIrById(store, workflowId);
|
|
const declared = ir.version === "v2" ? ir.settings : undefined;
|
|
if (declared && declared.length > 0) return declared;
|
|
if (isBuiltinWorkflowId(workflowId)) return BUILTIN_WORKFLOW_SETTINGS;
|
|
return declared;
|
|
}
|
|
|
|
export function createWorkflowSettingsTool(store: TaskStore): ToolDefinition {
|
|
return {
|
|
name: "fn_workflow_settings",
|
|
label: "Workflow Settings",
|
|
description:
|
|
"Read or write a workflow's setting VALUES (the per-(workflow, project) policy knobs: step " +
|
|
"timeouts, review/approval gates, per-phase model lanes). action='get' returns both the raw " +
|
|
"`stored` values and the engine `effective` values (declaration defaults filled in, orphaned " +
|
|
"values dropped). action='set' writes `values` against the NAMED workflow's declared settings; " +
|
|
"a `null` value clears an override. Built-in workflow VALUES are writable, but built-in " +
|
|
"DECLARATIONS are not — declarations are authored in the workflow IR's `settings` array via " +
|
|
"fn_workflow_create/update. An invalid value returns the typed rejection list and persists nothing.",
|
|
parameters: workflowSettingsParams,
|
|
execute: async (_id: string, params: Static<typeof workflowSettingsParams>) => {
|
|
const workflowId = params.workflow_id?.trim();
|
|
if (!workflowId) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "ERROR: workflow_id is required." }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
let projectId: string;
|
|
try {
|
|
// Resolve the project key the same way the engine resolver does, so agent
|
|
// reads/writes share the store's single project scope.
|
|
projectId = store.getWorkflowSettingsProjectId();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Failed to resolve project: ${err?.message ?? err}` }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (params.action === "get") {
|
|
try {
|
|
const stored = store.getWorkflowSettingValues(workflowId, projectId);
|
|
const effective = await resolveEffectiveSettingsById(store, workflowId, projectId);
|
|
const declarations = await resolveWorkflowSettingDeclarationsForTool(store, workflowId);
|
|
const orphaned = findOrphanedSettingValues(declarations, stored);
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: JSON.stringify({ workflowId, stored, effective, orphaned }, null, 2),
|
|
}],
|
|
details: { workflowId, stored, effective, orphaned },
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Failed to read workflow settings: ${err?.message ?? err}` }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
// action === "set"
|
|
const values = params.values;
|
|
if (!values || Object.keys(values).length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "ERROR: action='set' requires a non-empty `values` map." }],
|
|
details: { error: "No values provided" },
|
|
isError: true,
|
|
};
|
|
}
|
|
try {
|
|
const next = await store.updateWorkflowSettingValues(workflowId, projectId, values);
|
|
const effective = await resolveEffectiveSettingsById(store, workflowId, projectId);
|
|
const declarations = await resolveWorkflowSettingDeclarationsForTool(store, workflowId);
|
|
const orphaned = findOrphanedSettingValues(declarations, next);
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Updated workflow settings for ${workflowId}: ${JSON.stringify(next)}`,
|
|
}],
|
|
details: { workflowId, stored: next, effective, orphaned },
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (err: any) {
|
|
// Surface the typed rejection list the same way other value writers do
|
|
// (mirrors the custom-field rejection contract) — flat, JSON-safe, with
|
|
// machine-stable codes the agent can branch on and retry.
|
|
if (err instanceof WorkflowSettingRejectionError || err?.name === "WorkflowSettingRejectionError") {
|
|
const rejections = err.rejections ?? [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const summary = rejections.map((r: any) => `${r.settingId} (${r.code})`).join(", ");
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `ERROR: Rejected workflow setting value(s): ${summary}. Nothing was persisted.`,
|
|
}],
|
|
details: { workflowId, rejections },
|
|
isError: true,
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Failed to write workflow settings: ${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): workflow discovery, selection, mutation,
|
|
* settings, and `fn_trait_list` (trait vocabulary needed to author/update
|
|
* workflow IR).
|
|
*
|
|
* FNXC:WorkflowAuthoringTools 2026-06-29-23:38:
|
|
* Prompt-injectable chat/planning lanes need settings and trait discovery next to create/update/delete so agents can author policy-aware workflows without falling back to unpublished surfaces.
|
|
* Centralizing the list keeps those lanes from drifting away as new workflow authoring 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),
|
|
createWorkflowSettingsTool(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;
|
|
}
|
|
|
|
const GOAL_LIST_HARD_LIMIT = 5;
|
|
const GOAL_LIST_SOFT_WARNING_THRESHOLD = 3;
|
|
const GOAL_SNIPPET_MAX_CHARS = 80;
|
|
|
|
type GoalAuditContext = {
|
|
runId?: string;
|
|
agentId?: string;
|
|
taskId?: string;
|
|
};
|
|
|
|
type GoalListDetailsEntry = {
|
|
id: string;
|
|
title: string;
|
|
status: GoalStatus;
|
|
snippet?: string;
|
|
};
|
|
|
|
function buildGoalSnippet(description?: string): string | undefined {
|
|
const firstLine = description?.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim();
|
|
if (!firstLine) return undefined;
|
|
if (firstLine.length <= GOAL_SNIPPET_MAX_CHARS) return firstLine;
|
|
return `${firstLine.slice(0, GOAL_SNIPPET_MAX_CHARS - 1).trimEnd()}…`;
|
|
}
|
|
|
|
function buildGoalListDetailsEntry(goal: { id: string; title: string; status: GoalStatus; description?: string }): GoalListDetailsEntry {
|
|
const snippet = buildGoalSnippet(goal.description);
|
|
return snippet
|
|
? { id: goal.id, title: goal.title, status: goal.status, snippet }
|
|
: { id: goal.id, title: goal.title, status: goal.status };
|
|
}
|
|
|
|
function formatGoalListLine(goal: GoalListDetailsEntry): string {
|
|
return `- ${goal.id} [${goal.status}] ${goal.title}${goal.snippet ? ` — ${goal.snippet}` : ""}`;
|
|
}
|
|
|
|
function resolveGoalAuditContext(
|
|
ctx: unknown,
|
|
runContext?: RunMutationContext,
|
|
taskId?: string,
|
|
): GoalAuditContext {
|
|
const candidate = typeof ctx === "object" && ctx !== null ? ctx as Record<string, unknown> : {};
|
|
const runId = typeof candidate.runId === "string" ? candidate.runId : runContext?.runId;
|
|
const agentId = typeof candidate.agentId === "string" ? candidate.agentId : runContext?.agentId;
|
|
const resolvedTaskId = typeof candidate.taskId === "string" ? candidate.taskId : taskId;
|
|
return { runId, agentId, taskId: resolvedTaskId };
|
|
}
|
|
|
|
export function createGoalListTool(
|
|
store: TaskStore,
|
|
options?: { runContext?: RunMutationContext; taskId?: string },
|
|
): ToolDefinition {
|
|
return {
|
|
name: "fn_goal_list",
|
|
label: "List Goals",
|
|
description: "List goals by status with active-goal warning details.",
|
|
parameters: goalListParams,
|
|
execute: async (_id: string, params: Static<typeof goalListParams>, _signal, _onUpdate, ctx) => {
|
|
const goalStore = store.getGoalStore();
|
|
const status = params.status ?? "active";
|
|
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
|
|
const activeCount = goalStore.listGoals({ status: "active" }).length;
|
|
const softWarning = activeCount >= GOAL_LIST_SOFT_WARNING_THRESHOLD;
|
|
const goalEntries = goals.map(buildGoalListDetailsEntry);
|
|
|
|
emitGoalRetrievalAudit(
|
|
store,
|
|
resolveGoalAuditContext(ctx, options?.runContext, options?.taskId),
|
|
{
|
|
toolName: "fn_goal_list",
|
|
resultCount: goals.length,
|
|
goalIds: goals.map((goal) => goal.id),
|
|
},
|
|
);
|
|
|
|
const lines: string[] = [
|
|
`Goals (${goals.length}) [filter: ${status}]`,
|
|
`Active: ${activeCount}/${GOAL_LIST_HARD_LIMIT}`,
|
|
];
|
|
if (softWarning) {
|
|
lines.push(`⚠ ${GOAL_LIST_SOFT_WARNING_THRESHOLD}/${GOAL_LIST_HARD_LIMIT} active goals — soft warning at ${GOAL_LIST_SOFT_WARNING_THRESHOLD}, hard cap at ${GOAL_LIST_HARD_LIMIT}`);
|
|
}
|
|
lines.push("");
|
|
if (goalEntries.length === 0) {
|
|
lines.push("No goals found.");
|
|
} else {
|
|
lines.push(...goalEntries.map(formatGoalListLine));
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
details: {
|
|
goals: goalEntries,
|
|
activeCount,
|
|
softWarning,
|
|
hardLimit: GOAL_LIST_HARD_LIMIT,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createGoalShowTool(
|
|
store: TaskStore,
|
|
options?: { runContext?: RunMutationContext; taskId?: string },
|
|
): ToolDefinition {
|
|
return {
|
|
name: "fn_goal_show",
|
|
label: "Show Goal",
|
|
description: "Show full details for a single goal by ID.",
|
|
parameters: goalShowParams,
|
|
execute: async (_id: string, params: Static<typeof goalShowParams>, _signal, _onUpdate, ctx) => {
|
|
const goalStore = store.getGoalStore();
|
|
const goal = goalStore.getGoal(params.id);
|
|
const auditContext = resolveGoalAuditContext(ctx, options?.runContext, options?.taskId);
|
|
|
|
if (!goal) {
|
|
emitGoalRetrievalAudit(store, auditContext, {
|
|
toolName: "fn_goal_show",
|
|
resultCount: 0,
|
|
goalId: params.id,
|
|
goalIds: [],
|
|
notFound: true,
|
|
});
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: `Goal ${params.id} not found` }],
|
|
details: { code: "GOAL_NOT_FOUND", goalId: params.id },
|
|
};
|
|
}
|
|
|
|
const lines = [
|
|
`${goal.id}: ${goal.title}`,
|
|
`Status: ${goal.status}`,
|
|
`Created: ${goal.createdAt}`,
|
|
`Updated: ${goal.updatedAt}`,
|
|
...(goal.description ? [`Description: ${goal.description}`] : []),
|
|
];
|
|
|
|
emitGoalRetrievalAudit(store, auditContext, {
|
|
toolName: "fn_goal_show",
|
|
resultCount: 1,
|
|
goalId: params.id,
|
|
goalIds: [params.id],
|
|
});
|
|
|
|
return {
|
|
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
details: { goal },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createGoalRetrievalTools(
|
|
store: TaskStore,
|
|
options?: { runContext?: RunMutationContext; taskId?: string },
|
|
): ToolDefinition[] {
|
|
return [
|
|
createGoalListTool(store, options),
|
|
createGoalShowTool(store, options),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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 = await Promise.all(agents.map(async (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) {
|
|
/*
|
|
FNXC:AgentTaskStateDrift 2026-06-27-16:05:
|
|
Show the linked task column in fn_list_agents so parked triage/todo ownership is not mistaken for an in-progress execution mismatch.
|
|
*/
|
|
const linkedTask = await agentStore.resolveCurrentTaskLink(agent.taskId);
|
|
parts.push(formatCurrentTaskLine(agent.taskId, linkedTask));
|
|
}
|
|
|
|
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. " +
|
|
"Optionally pass workflow_id to select a workflow at creation time; use " +
|
|
"fn_workflow_list to discover valid IDs.",
|
|
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 {
|
|
const workflowId = params.workflow_id?.trim() || undefined;
|
|
// 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,
|
|
...(workflowId ? { workflowId } : {}),
|
|
source: {
|
|
sourceType: "api",
|
|
...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}),
|
|
},
|
|
}, options);
|
|
|
|
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
|
const workflow = workflowId ? ` (workflow: ${workflowId})` : "";
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: `Delegated to ${agent.name} (${agent.id}): ${wasDuplicate ? "Linked existing" : "Created"} ${task.id}${deps}${workflow}. ` +
|
|
`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;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
type AskQuestionInput = Static<typeof askQuestionParams>;
|
|
|
|
function askQuestionError(message: string) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatAskQuestion 2026-06-17-13:08:
|
|
* Dashboard chat agents need a provider-agnostic `fn_ask_question` tool that emits the FN-6501 structured question payload, renders through the existing chat question UI, and receives the answer through the normal next user message instead of a blocking tool response.
|
|
*
|
|
* Create a `fn_ask_question` tool that asks the dashboard user structured questions.
|
|
*
|
|
* @returns ToolDefinition for the `fn_ask_question` tool
|
|
*/
|
|
export function createAskQuestionTool(): ToolDefinition {
|
|
return {
|
|
name: "fn_ask_question",
|
|
label: "Ask User Question",
|
|
description:
|
|
"Ask the user a structured question (single-select, multi-select, free-text, or yes/no confirm). " +
|
|
"The question renders as an interactive card in chat. After calling this tool, end the turn and wait; " +
|
|
"the user's answer arrives as the next message.",
|
|
parameters: askQuestionParams,
|
|
execute: async (_id: string, params: AskQuestionInput) => {
|
|
if (!Array.isArray(params.questions) || params.questions.length === 0) {
|
|
return askQuestionError("questions must contain at least one question");
|
|
}
|
|
|
|
for (const [index, question] of params.questions.entries()) {
|
|
const questionText = typeof question.question === "string" ? question.question.trim() : "";
|
|
if (!questionText) {
|
|
return askQuestionError(`questions[${index}].question must be a non-empty string`);
|
|
}
|
|
|
|
const optionCount = Array.isArray(question.options)
|
|
? question.options.filter((option) => typeof option.label === "string" && option.label.trim().length > 0).length
|
|
: 0;
|
|
const requiresOptions = question.type === "single_select"
|
|
|| question.type === "multi_select"
|
|
|| question.multiSelect === true;
|
|
if (requiresOptions && optionCount === 0) {
|
|
return askQuestionError(`questions[${index}] select questions must include at least one option`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: "text" as const,
|
|
text: "Question presented to the user. Stop and wait for their reply on the next turn.",
|
|
}],
|
|
details: { questionCount: params.questions.length },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createAcquireRepoWorktreeTool(opts: {
|
|
workspaceRootDir: string;
|
|
workspaceRepos: string[];
|
|
task: import("@fusion/core").Task;
|
|
store: TaskStore;
|
|
settings: Partial<Settings>;
|
|
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
|
secretsStore?: Pick<import("@fusion/core").SecretsStore, "listEnvExportable">;
|
|
runContext?: RunMutationContext;
|
|
audit?: Pick<RunAuditor, "git" | "filesystem">;
|
|
/*
|
|
FNXC:Workspace 2026-06-21-22:30:
|
|
F2 — executor-supplied callback invoked after a SUCCESSFUL fresh acquire so the
|
|
acquired sub-repo worktree path is registered in the executor's per-task
|
|
activeWorktrees Set (KTD2). Without this the Set only ever held the browse-only
|
|
root and the "task holds N sub-repo paths" invariant was hollow — owner/liveness
|
|
checks never saw live sub-repo worktrees. Not called on the already-acquired
|
|
short-circuit (the path was registered on the original fresh acquire).
|
|
*/
|
|
onAcquired?: (worktreePath: string) => void;
|
|
// FNXC:Workspace 2026-06-22 — thread the configured worktree-init runner so sub-repo worktrees run configured setup.
|
|
runConfiguredCommand?: import("./worktree-acquisition.js").AcquireWorkspaceRepoWorktreeOptions["runConfiguredCommand"];
|
|
taskEnv?: NodeJS.ProcessEnv;
|
|
}): ToolDefinition {
|
|
const { workspaceRootDir, workspaceRepos, task, store, settings, logger, secretsStore, runContext, audit, onAcquired, runConfiguredCommand, taskEnv } = opts;
|
|
return {
|
|
name: "fn_acquire_repo_worktree",
|
|
label: "Acquire Repo Worktree",
|
|
description:
|
|
"Acquire an isolated git worktree for a sub-repo in this workspace. " +
|
|
"Call this before editing files in a sub-repo; work in the returned path. " +
|
|
`Available repos: ${workspaceRepos.join(", ")}.`,
|
|
parameters: acquireRepoWorktreeParams,
|
|
execute: async (_id: string, params: Static<typeof acquireRepoWorktreeParams>) => {
|
|
const { repo } = params;
|
|
if (!workspaceRepos.includes(repo)) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Unknown repo: "${repo}". Available: ${workspaceRepos.join(", ")}` }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
const freshTask = await store.getTask(task.id);
|
|
/*
|
|
FNXC:Workspace 2026-06-21-22:30:
|
|
F1 — acquireWorkspaceRepoWorktree can throw WorkspaceRepoAcquireBusyError on
|
|
same-sub-repo contention (KTD4) or a generic failure. Both must surface as a
|
|
structured isError tool result, never an uncaught throw that crashes the agent
|
|
loop. The busy message is sanitized — it does NOT leak the holder task id into
|
|
agent-facing text (only into details). runContext is forwarded so the helper's
|
|
audit/log entries keep run attribution.
|
|
*/
|
|
let result: Awaited<ReturnType<typeof acquireWorkspaceRepoWorktree>>;
|
|
try {
|
|
result = await acquireWorkspaceRepoWorktree({
|
|
repoRelPath: repo,
|
|
workspaceRootDir,
|
|
task: freshTask,
|
|
store,
|
|
settings,
|
|
logger,
|
|
secretsStore,
|
|
audit,
|
|
runContext,
|
|
runConfiguredCommand,
|
|
taskEnv,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof WorkspaceRepoAcquireBusyError) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `Sub-repo ${repo} is temporarily locked by another task's acquisition; retry fn_acquire_repo_worktree shortly.` }],
|
|
details: { holderTaskId: err.holderTaskId },
|
|
isError: true,
|
|
};
|
|
}
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: "text" as const, text: `ERROR: Failed to acquire worktree for ${repo}: ${message}` }],
|
|
details: {},
|
|
isError: true,
|
|
};
|
|
}
|
|
// FNXC:Workspace 2026-06-21-22:30: F2 — register a freshly-acquired sub-repo worktree in the executor's activeWorktrees Set (KTD2) so owner/liveness checks see live per-repo worktrees, not just the browse-only root.
|
|
// FNXC:Workspace 2026-06-22-09:00: register UNCONDITIONALLY, including the
|
|
// already-acquired short-circuit. After an executor restart activeWorktrees is an
|
|
// empty Map; a resumed workspace task with pre-existing task.workspaceWorktrees hits
|
|
// the alreadyAcquired path, so skipping onAcquired left the sub-repo path unregistered
|
|
// in-memory and conflict/liveness checks missed it. Set.add is idempotent, so re-firing
|
|
// on a fresh acquire is a harmless no-op.
|
|
onAcquired?.(result.worktreePath);
|
|
await store.logEntry(
|
|
task.id,
|
|
result.alreadyAcquired
|
|
? `fn_acquire_repo_worktree: reusing existing worktree for ${repo} at ${result.worktreePath}`
|
|
: `fn_acquire_repo_worktree: created worktree for ${repo} at ${result.worktreePath} (branch: ${result.branch})`,
|
|
undefined,
|
|
runContext,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: `Worktree ready at: ${result.worktreePath} (branch: ${result.branch}, alreadyAcquired: ${result.alreadyAcquired})` }],
|
|
details: result,
|
|
};
|
|
},
|
|
};
|
|
}
|