feat(FN-3033): align agent asset directory naming and heartbeat path compat
The merge delivers three major features: a droid CLI path reconciliation extension that resolves workspace path mismatches for spawned agents, session-first quick chat with improved heartbeat prompts and a dramatically simplified QuickChatFAB component, and canonical agent asset directory naming wit Fusion-Task-Id: FN-3033
This commit is contained in:
5
.changeset/fn-3033-agent-directory-naming.md
Normal file
5
.changeset/fn-3033-agent-directory-naming.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix per-agent filesystem defaults to use display-name-plus-id directories (for example `ceo-agent2736`) for heartbeat procedure files and managed instruction bundles, while preserving compatibility with legacy id-only and previously created display-name-based paths. Existing agent files are reused in place and are not auto-renamed or deleted during upgrades.
|
||||||
@@ -227,9 +227,12 @@ The **Settings** tab in the Agent Detail modal includes a **Heartbeat Procedure*
|
|||||||
|
|
||||||
### Relation to upgrade flow
|
### Relation to upgrade flow
|
||||||
|
|
||||||
- **Upgrade to Default Heartbeat Procedure** still sets `heartbeatProcedurePath` to:
|
- Canonical per-agent asset directories now use **display name + immutable id suffix** (example: `ceo-agent2736`).
|
||||||
- `.fusion/agents/{agent.id}/HEARTBEAT.md`
|
- Canonical heartbeat path example: `.fusion/agents/ceo-agent2736/HEARTBEAT.md`
|
||||||
- If the default file does not exist yet, the backend seeds it from the built-in template.
|
- Canonical managed bundle directory example: `.fusion/agents/ceo-agent2736-instructions/`
|
||||||
|
- Legacy id-only paths (for example `.fusion/agents/{agent.id}/HEARTBEAT.md`) and previously created display-name-based paths remain supported.
|
||||||
|
- Upgrade/create flows preserve existing compatible files and directories in place; Fusion does **not** auto-rename or delete old paths.
|
||||||
|
- If the selected default file does not exist yet, the backend seeds it from the built-in template.
|
||||||
- After upgrade completes and the agent refreshes, operators can immediately open the seeded per-agent `HEARTBEAT.md` from the same modal section.
|
- After upgrade completes and the agent refreshes, operators can immediately open the seeded per-agent `HEARTBEAT.md` from the same modal section.
|
||||||
|
|
||||||
## New Agent Presets (Dashboard UI)
|
## New Agent Presets (Dashboard UI)
|
||||||
@@ -245,7 +248,7 @@ The custom tab exposes separate fields for:
|
|||||||
|
|
||||||
- **Title** (`title`) — optional role title/description
|
- **Title** (`title`) — optional role title/description
|
||||||
- **Soul** (`soul`) — optional personality and communication style guidance
|
- **Soul** (`soul`) — optional personality and communication style guidance
|
||||||
- **Heartbeat Procedure Path** (`heartbeatProcedurePath`) — optional path to the agent heartbeat markdown file, typically `.fusion/agents/<agent-id>/HEARTBEAT.md`
|
- **Heartbeat Procedure Path** (`heartbeatProcedurePath`) — optional path to the agent heartbeat markdown file, typically `.fusion/agents/<display-name>-<agent-id>/HEARTBEAT.md` (legacy id-only paths remain valid)
|
||||||
- **Instructions Path** (`instructionsPath`) — optional file-backed instructions path
|
- **Instructions Path** (`instructionsPath`) — optional file-backed instructions path
|
||||||
- **Inline Instructions** (`instructionsText`) — optional inline behavior instructions
|
- **Inline Instructions** (`instructionsText`) — optional inline behavior instructions
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promise
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { AgentStore } from "../agent-store.js";
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import {
|
||||||
|
getCanonicalAgentInstructionsBundleDirName,
|
||||||
|
getLegacyAgentInstructionsBundleDirName,
|
||||||
|
getSafeAgentAssetIdSegment,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
describe("AgentStore — instructions bundle", () => {
|
describe("AgentStore — instructions bundle", () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
@@ -62,7 +67,9 @@ describe("AgentStore — instructions bundle", () => {
|
|||||||
it("getInstructionsDir returns the managed bundle directory path", async () => {
|
it("getInstructionsDir returns the managed bundle directory path", async () => {
|
||||||
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
|
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
|
||||||
createdAgentIds.push(agent.id);
|
createdAgentIds.push(agent.id);
|
||||||
expect(store.getInstructionsDir(agent.id)).toBe(join(testDir, "agents", `${agent.id}-instructions`));
|
expect(store.getInstructionsDir(agent.id)).toBe(
|
||||||
|
join(testDir, "agents", getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
|
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
|
||||||
@@ -273,4 +280,27 @@ describe("AgentStore — instructions bundle", () => {
|
|||||||
expect(migrated.instructionsText).toBeUndefined();
|
expect(migrated.instructionsText).toBeUndefined();
|
||||||
expect(migrated.instructionsPath).toBeUndefined();
|
expect(migrated.instructionsPath).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses existing legacy id-only instructions directory when present", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Legacy Bundle", role: "executor" });
|
||||||
|
createdAgentIds.push(agent.id);
|
||||||
|
|
||||||
|
const legacyDir = join(testDir, "agents", getLegacyAgentInstructionsBundleDirName(agent.id));
|
||||||
|
await mkdir(legacyDir, { recursive: true });
|
||||||
|
await writeFile(join(legacyDir, "AGENTS.md"), "legacy content", "utf-8");
|
||||||
|
|
||||||
|
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("legacy content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses previously-created display-name instructions directory for same id", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Current Name", role: "executor" });
|
||||||
|
createdAgentIds.push(agent.id);
|
||||||
|
|
||||||
|
const priorDirName = `previous-name-${getSafeAgentAssetIdSegment(agent.id)}-instructions`;
|
||||||
|
const priorDir = join(testDir, "agents", priorDirName);
|
||||||
|
await mkdir(priorDir, { recursive: true });
|
||||||
|
await writeFile(join(priorDir, "AGENTS.md"), "existing display path", "utf-8");
|
||||||
|
|
||||||
|
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("existing display path");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import { join } from "node:path";
|
|||||||
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { CheckoutConflictError, type AgentCapability, type AgentState } from "../types.js";
|
import {
|
||||||
|
CheckoutConflictError,
|
||||||
|
getCanonicalAgentAssetDirectoryName,
|
||||||
|
type AgentCapability,
|
||||||
|
type AgentState,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
||||||
@@ -150,6 +155,27 @@ describe("AgentStore", () => {
|
|||||||
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
|
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults heartbeat procedure path to canonical display-name directory", async () => {
|
||||||
|
const agent = await store.createAgent({
|
||||||
|
name: "CEO",
|
||||||
|
role: "executor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
|
||||||
|
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to id-based segment when display-name slug is empty", async () => {
|
||||||
|
const agent = await store.createAgent({
|
||||||
|
name: "!!!",
|
||||||
|
role: "executor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
|
||||||
|
expect(expectedDir).toContain("agent-");
|
||||||
|
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves custom metadata", async () => {
|
it("preserves custom metadata", async () => {
|
||||||
const agent = await store.createAgent({
|
const agent = await store.createAgent({
|
||||||
name: "With Meta",
|
name: "With Meta",
|
||||||
|
|||||||
@@ -38,7 +38,19 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
AgentLogEntry,
|
AgentLogEntry,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
|
import {
|
||||||
|
AGENT_VALID_TRANSITIONS,
|
||||||
|
agentToConfigSnapshot,
|
||||||
|
diffConfigSnapshots,
|
||||||
|
isEphemeralAgent,
|
||||||
|
CheckoutConflictError,
|
||||||
|
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||||
|
getDefaultHeartbeatProcedurePath,
|
||||||
|
getCanonicalAgentInstructionsBundleDirName,
|
||||||
|
getLegacyAgentAssetDirectoryName,
|
||||||
|
getLegacyAgentInstructionsBundleDirName,
|
||||||
|
getSafeAgentAssetIdSegment,
|
||||||
|
} from "./types.js";
|
||||||
import type { RunMutationContext } from "./types.js";
|
import type { RunMutationContext } from "./types.js";
|
||||||
import type { TaskStore } from "./store.js";
|
import type { TaskStore } from "./store.js";
|
||||||
import { computeAccessState } from "./agent-permissions.js";
|
import { computeAccessState } from "./agent-permissions.js";
|
||||||
@@ -262,7 +274,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
|
const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent);
|
||||||
const newAbsPath = join(this.rootDir, "..", newRelPath);
|
const newAbsPath = join(this.rootDir, "..", newRelPath);
|
||||||
|
|
||||||
// Best-effort copy of operator edits to the new per-agent location.
|
// Best-effort copy of operator edits to the new per-agent location.
|
||||||
@@ -468,7 +480,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
// don't need persistent procedure files.
|
// don't need persistent procedure files.
|
||||||
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
|
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
|
||||||
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
||||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
|
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
|
||||||
|
|
||||||
const agent: Agent = {
|
const agent: Agent = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
@@ -742,7 +754,9 @@ export class AgentStore extends EventEmitter {
|
|||||||
* Does not create the directory.
|
* Does not create the directory.
|
||||||
*/
|
*/
|
||||||
getInstructionsDir(agentId: string): string {
|
getInstructionsDir(agentId: string): string {
|
||||||
return this.getBundleDir(agentId);
|
const agent = this.readAgent(agentId);
|
||||||
|
const agentName = agent?.name ?? "";
|
||||||
|
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agentName, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -750,7 +764,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
* Returns [] when the bundle directory does not exist.
|
* Returns [] when the bundle directory does not exist.
|
||||||
*/
|
*/
|
||||||
async listBundleFiles(agentId: string): Promise<string[]> {
|
async listBundleFiles(agentId: string): Promise<string[]> {
|
||||||
const bundleDir = this.getBundleDir(agentId);
|
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(bundleDir, { withFileTypes: true });
|
const entries = await readdir(bundleDir, { withFileTypes: true });
|
||||||
@@ -771,7 +785,8 @@ export class AgentStore extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async readBundleFile(agentId: string, filePath: string): Promise<string> {
|
async readBundleFile(agentId: string, filePath: string): Promise<string> {
|
||||||
this.validateBundleFilePath(filePath);
|
this.validateBundleFilePath(filePath);
|
||||||
const resolvedPath = join(this.getBundleDir(agentId), filePath);
|
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||||
|
const resolvedPath = join(bundleDir, filePath);
|
||||||
return readFile(resolvedPath, "utf-8");
|
return readFile(resolvedPath, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,7 +797,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
return this.withLock(agentId, async () => {
|
return this.withLock(agentId, async () => {
|
||||||
this.validateBundleFilePath(filePath);
|
this.validateBundleFilePath(filePath);
|
||||||
|
|
||||||
const bundleDir = this.getBundleDir(agentId);
|
const bundleDir = await this.resolveCompatibleBundleDir(agentId, true);
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
|
||||||
const existingFiles = await this.listBundleFiles(agentId);
|
const existingFiles = await this.listBundleFiles(agentId);
|
||||||
@@ -804,7 +819,8 @@ export class AgentStore extends EventEmitter {
|
|||||||
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
|
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
|
||||||
return this.withLock(agentId, async () => {
|
return this.withLock(agentId, async () => {
|
||||||
this.validateBundleFilePath(filePath);
|
this.validateBundleFilePath(filePath);
|
||||||
await unlink(join(this.getBundleDir(agentId), filePath));
|
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||||
|
await unlink(join(bundleDir, filePath));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +847,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
|
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
|
||||||
|
|
||||||
if (normalizedConfig.mode === "managed") {
|
if (normalizedConfig.mode === "managed") {
|
||||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
@@ -860,7 +876,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
|
||||||
|
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
|
|
||||||
@@ -2057,8 +2073,108 @@ export class AgentStore extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBundleDir(agentId: string): string {
|
private getCanonicalBundleDir(agent: Agent): string {
|
||||||
return join(this.agentsDir, `${agentId}-instructions`);
|
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLegacyBundleDir(agentId: string): string {
|
||||||
|
return join(this.agentsDir, getLegacyAgentInstructionsBundleDirName(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCompatibleBundleDir(agentId: string, createIfMissing: boolean): Promise<string> {
|
||||||
|
const agent = this.readAgent(agentId);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalDir = this.getCanonicalBundleDir(agent);
|
||||||
|
if (await this.pathExists(canonicalDir)) {
|
||||||
|
return canonicalDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatibleDir = await this.findExistingDisplayNameBundleDir(agent);
|
||||||
|
if (compatibleDir) {
|
||||||
|
return compatibleDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDir = this.getLegacyBundleDir(agent.id);
|
||||||
|
if (await this.pathExists(legacyDir)) {
|
||||||
|
return legacyDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createIfMissing ? canonicalDir : canonicalDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findExistingDisplayNameBundleDir(agent: Agent): Promise<string | null> {
|
||||||
|
const safeId = getSafeAgentAssetIdSegment(agent.id);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(this.agentsDir, { withFileTypes: true });
|
||||||
|
const candidates = entries
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith("-instructions"))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.filter((name) => {
|
||||||
|
const base = name.slice(0, -"-instructions".length);
|
||||||
|
return base.endsWith(`-${safeId}`);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalName = getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id);
|
||||||
|
const selected = candidates.find((candidate) => candidate === canonicalName) ?? candidates[0];
|
||||||
|
return join(this.agentsDir, selected);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCompatibleHeartbeatProcedurePath(agent: Agent): Promise<string> {
|
||||||
|
const canonicalPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
|
||||||
|
const canonicalAbs = join(this.rootDir, "..", canonicalPath);
|
||||||
|
if (await this.pathExists(canonicalAbs)) {
|
||||||
|
return canonicalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeId = getSafeAgentAssetIdSegment(agent.id);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(this.agentsDir, { withFileTypes: true });
|
||||||
|
const compatibleDir = entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.find((name) => name.endsWith(`-${safeId}`));
|
||||||
|
if (compatibleDir) {
|
||||||
|
const candidatePath = `.fusion/agents/${compatibleDir}/HEARTBEAT.md`;
|
||||||
|
if (await this.pathExists(join(this.rootDir, "..", candidatePath))) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyPath = `.fusion/agents/${getLegacyAgentAssetDirectoryName(agent.id)}/HEARTBEAT.md`;
|
||||||
|
const legacyAbs = join(this.rootDir, "..", legacyPath);
|
||||||
|
if (await this.pathExists(legacyAbs)) {
|
||||||
|
return legacyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, fsConstants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateBundleFilePath(filePath: string): void {
|
private validateBundleFilePath(filePath: string): void {
|
||||||
|
|||||||
@@ -3263,20 +3263,71 @@ export interface AgentConfigRevision {
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
|
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
|
||||||
|
|
||||||
|
function slugifyAgentAssetSegment(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeAgentAssetIdSegment(agentId: string): string {
|
||||||
|
const slug = slugifyAgentAssetSegment(agentId);
|
||||||
|
return slug || "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the canonical per-agent asset directory segment.
|
||||||
|
*
|
||||||
|
* Canonical format: `<slugged-display-name>-<safe-agent-id>`.
|
||||||
|
* Example: `CEO` + `agent2736` => `ceo-agent2736`.
|
||||||
|
*
|
||||||
|
* If the display-name slug is empty (for example name has only symbols), the
|
||||||
|
* id-derived segment is used as the directory prefix so the result is always
|
||||||
|
* filesystem-safe and non-empty.
|
||||||
|
*/
|
||||||
|
export function getCanonicalAgentAssetDirectoryName(agentName: string, agentId: string): string {
|
||||||
|
if (!agentId || typeof agentId !== "string") {
|
||||||
|
throw new Error("getCanonicalAgentAssetDirectoryName requires a non-empty agentId");
|
||||||
|
}
|
||||||
|
const safeId = getSafeAgentAssetIdSegment(agentId);
|
||||||
|
const nameSlug = slugifyAgentAssetSegment(agentName ?? "");
|
||||||
|
const prefix = nameSlug || safeId;
|
||||||
|
return `${prefix}-${safeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy per-agent asset directory segment used by older builds. */
|
||||||
|
export function getLegacyAgentAssetDirectoryName(agentId: string): string {
|
||||||
|
if (!agentId || typeof agentId !== "string") {
|
||||||
|
throw new Error("getLegacyAgentAssetDirectoryName requires a non-empty agentId");
|
||||||
|
}
|
||||||
|
return agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical managed instruction bundle directory name for an agent. */
|
||||||
|
export function getCanonicalAgentInstructionsBundleDirName(agentName: string, agentId: string): string {
|
||||||
|
return `${getCanonicalAgentAssetDirectoryName(agentName, agentId)}-instructions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy managed instruction bundle directory name used by older builds. */
|
||||||
|
export function getLegacyAgentInstructionsBundleDirName(agentId: string): string {
|
||||||
|
return `${getLegacyAgentAssetDirectoryName(agentId)}-instructions`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the project-relative default heartbeat procedure file path for a
|
* Compute the project-relative default heartbeat procedure file path for a
|
||||||
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
|
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
|
||||||
* can tune the per-tick procedure without changes leaking across the team.
|
* can tune the per-tick procedure without changes leaking across the team.
|
||||||
*
|
*
|
||||||
* The path is laid out under `.fusion/agents/<agentId>/HEARTBEAT.md` so it
|
* The path is laid out under `.fusion/agents/<canonical-agent-dir>/HEARTBEAT.md`.
|
||||||
* lives alongside any other future per-agent assets and survives agent
|
|
||||||
* renames (which do not change the immutable agent id).
|
|
||||||
*/
|
*/
|
||||||
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
|
export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: string): string {
|
||||||
if (!agentId || typeof agentId !== "string") {
|
if (!agentId || typeof agentId !== "string") {
|
||||||
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
|
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
|
||||||
}
|
}
|
||||||
return `.fusion/agents/${agentId}/HEARTBEAT.md`;
|
const directory = agentName
|
||||||
|
? getCanonicalAgentAssetDirectoryName(agentName, agentId)
|
||||||
|
: getLegacyAgentAssetDirectoryName(agentId);
|
||||||
|
return `.fusion/agents/${directory}/HEARTBEAT.md`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract trackable config fields from an Agent into a snapshot */
|
/** Extract trackable config fields from an Agent into a snapshot */
|
||||||
|
|||||||
@@ -2500,8 +2500,21 @@ function HeartbeatProcedureSection({
|
|||||||
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
|
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
|
||||||
const [justSavedFile, setJustSavedFile] = useState(false);
|
const [justSavedFile, setJustSavedFile] = useState(false);
|
||||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||||
const expectedDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
|
const canonicalDefaultPath = `.fusion/agents/${agent.name
|
||||||
const onDefault = currentPath === expectedDefaultPath;
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "agent"}-${agent.id
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || "agent"}/HEARTBEAT.md`;
|
||||||
|
const legacyDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
|
||||||
|
const safeId = agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
||||||
|
const onDefault = Boolean(
|
||||||
|
currentPath
|
||||||
|
&& (currentPath === canonicalDefaultPath
|
||||||
|
|| currentPath === legacyDefaultPath
|
||||||
|
|| new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(currentPath)),
|
||||||
|
);
|
||||||
const hasFilePath = Boolean(currentPath);
|
const hasFilePath = Boolean(currentPath);
|
||||||
|
|
||||||
const loadHeartbeatFile = useCallback(async (path: string) => {
|
const loadHeartbeatFile = useCallback(async (path: string) => {
|
||||||
@@ -2575,9 +2588,10 @@ function HeartbeatProcedureSection({
|
|||||||
<div className="config-section">
|
<div className="config-section">
|
||||||
<h3>Heartbeat Procedure</h3>
|
<h3>Heartbeat Procedure</h3>
|
||||||
<p className="config-description">
|
<p className="config-description">
|
||||||
The per-tick procedure this agent runs every wake. Defaults to a project-level
|
The per-tick procedure this agent runs every wake. Defaults to a per-agent
|
||||||
markdown file you can edit. Resets on every tick — no need to restart the agent
|
markdown file (for example <code>.fusion/agents/ceo-agent2736/HEARTBEAT.md</code>)
|
||||||
after editing.
|
that you can edit. Legacy id-only default paths remain valid. Resets on every tick —
|
||||||
|
no need to restart the agent after editing.
|
||||||
</p>
|
</p>
|
||||||
<div className="config-fields">
|
<div className="config-fields">
|
||||||
<div className="config-field">
|
<div className="config-field">
|
||||||
@@ -2629,7 +2643,7 @@ function HeartbeatProcedureSection({
|
|||||||
</button>
|
</button>
|
||||||
<span className="config-hint">
|
<span className="config-hint">
|
||||||
Sets <code>heartbeatProcedurePath</code> to{" "}
|
Sets <code>heartbeatProcedurePath</code> to{" "}
|
||||||
<code>{expectedDefaultPath}</code>
|
<code>{canonicalDefaultPath}</code>
|
||||||
{" "}and seeds the file from the built-in template if it doesn't exist.
|
{" "}and seeds the file from the built-in template if it doesn't exist.
|
||||||
Each agent gets its own per-agent file, so edits stay scoped to this agent.
|
Each agent gets its own per-agent file, so edits stay scoped to this agent.
|
||||||
Operator edits to the file are preserved.
|
Operator edits to the file are preserved.
|
||||||
|
|||||||
@@ -599,12 +599,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
|||||||
id="agent-heartbeat-procedure-path"
|
id="agent-heartbeat-procedure-path"
|
||||||
type="text"
|
type="text"
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md"
|
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||||
value={heartbeatProcedurePath}
|
value={heartbeatProcedurePath}
|
||||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="agent-dialog-optional agent-dialog-field-hint">
|
<p className="agent-dialog-optional agent-dialog-field-hint">
|
||||||
Path to the agent's heartbeat markdown file, typically .fusion/agents/<agent-id>/HEARTBEAT.md.
|
Path to the agent's heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-dialog-field">
|
<div className="agent-dialog-field">
|
||||||
@@ -742,7 +742,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
|||||||
id="agent-review-heartbeat-procedure-path"
|
id="agent-review-heartbeat-procedure-path"
|
||||||
type="text"
|
type="text"
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md"
|
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||||
value={heartbeatProcedurePath}
|
value={heartbeatProcedurePath}
|
||||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type AgentRecord = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
reportsTo?: string;
|
reportsTo?: string;
|
||||||
|
heartbeatProcedurePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -51,8 +52,11 @@ vi.mock("@fusion/core", () => {
|
|||||||
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
||||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md",
|
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md",
|
||||||
getDefaultHeartbeatProcedurePath: (agentId: string) =>
|
getDefaultHeartbeatProcedurePath: (agentId: string, agentName?: string) =>
|
||||||
`.fusion/agents/${agentId}/HEARTBEAT.md`,
|
agentName
|
||||||
|
? `.fusion/agents/${agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}-${agentId}/HEARTBEAT.md`
|
||||||
|
: `.fusion/agents/${agentId}/HEARTBEAT.md`,
|
||||||
|
getSafeAgentAssetIdSegment: (agentId: string) => agentId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +103,7 @@ describe("Agent skills routes", () => {
|
|||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
metadata: input.metadata ?? {},
|
metadata: input.metadata ?? {},
|
||||||
|
heartbeatProcedurePath: input.heartbeatProcedurePath,
|
||||||
};
|
};
|
||||||
agents.set(id, agent);
|
agents.set(id, agent);
|
||||||
return agent;
|
return agent;
|
||||||
@@ -184,6 +189,35 @@ describe("Agent skills routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/agents/:id/upgrade-heartbeat-procedure", () => {
|
||||||
|
it("preserves existing legacy default heartbeat path", async () => {
|
||||||
|
agents.set("agent-001", {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Legacy Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "idle",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
metadata: {},
|
||||||
|
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/agents/agent-001/upgrade-heartbeat-procedure",
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||||
|
"agent-001",
|
||||||
|
expect.objectContaining({ heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PATCH /api/agents/:id", () => {
|
describe("PATCH /api/agents/:id", () => {
|
||||||
it("updates agent skills", async () => {
|
it("updates agent skills", async () => {
|
||||||
agents.set("agent-001", {
|
agents.set("agent-001", {
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ interface AgentCoreRouteDeps {
|
|||||||
validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean;
|
validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {
|
||||||
|
const trimmed = path?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (trimmed === getDefaultHeartbeatProcedurePath(agent.id, agent.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === getDefaultHeartbeatProcedurePath(agent.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const safeId = (agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent");
|
||||||
|
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
|
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
|
||||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||||
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
|
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
|
||||||
@@ -160,7 +175,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
|||||||
// the per-agent default path (which createAgent fills in for
|
// the per-agent default path (which createAgent fills in for
|
||||||
// non-ephemeral agents when no override is provided). Idempotent —
|
// non-ephemeral agents when no override is provided). Idempotent —
|
||||||
// operator edits are kept.
|
// operator edits are kept.
|
||||||
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id);
|
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
|
||||||
if (agent.heartbeatProcedurePath === expectedDefaultPath) {
|
if (agent.heartbeatProcedurePath === expectedDefaultPath) {
|
||||||
try {
|
try {
|
||||||
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
|
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
|
||||||
@@ -490,7 +505,9 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
|||||||
throw notFound(`agent ${req.params.id} not found`);
|
throw notFound(`agent ${req.params.id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = getDefaultHeartbeatProcedurePath(req.params.id);
|
const targetPath = isCompatibleDefaultHeartbeatPath(existing.heartbeatProcedurePath, existing)
|
||||||
|
? existing.heartbeatProcedurePath!
|
||||||
|
: getDefaultHeartbeatProcedurePath(existing.id, existing.name);
|
||||||
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
||||||
scopedStore.getRootDir(),
|
scopedStore.getRootDir(),
|
||||||
targetPath,
|
targetPath,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
resolveAgentInstructionsWithRatings,
|
resolveAgentInstructionsWithRatings,
|
||||||
buildAgentChatPrompt,
|
buildAgentChatPrompt,
|
||||||
buildSystemPromptWithInstructions,
|
buildSystemPromptWithInstructions,
|
||||||
|
ensureDefaultHeartbeatProcedureFile,
|
||||||
|
resolveAgentHeartbeatProcedure,
|
||||||
} from "../agent-instructions.js";
|
} from "../agent-instructions.js";
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||||
@@ -700,3 +702,61 @@ describe("diagnostics logging", () => {
|
|||||||
expect(prompt).toContain("## Identity");
|
expect(prompt).toContain("## Identity");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("heartbeat procedure path compatibility", () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDir = await mkdtemp(join(tmpdir(), "agent-heartbeat-proc-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads canonical display-name heartbeat procedure paths", async () => {
|
||||||
|
const relPath = ".fusion/agents/ceo-agent2736/HEARTBEAT.md";
|
||||||
|
await mkdir(join(testDir, ".fusion", "agents", "ceo-agent2736"), { recursive: true });
|
||||||
|
await writeFile(join(testDir, relPath), "Canonical heartbeat", "utf-8");
|
||||||
|
|
||||||
|
const content = await resolveAgentHeartbeatProcedure(
|
||||||
|
makeAgent({ id: "agent2736", heartbeatProcedurePath: relPath }),
|
||||||
|
testDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content).toBe("Canonical heartbeat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads legacy id-only heartbeat procedure paths", async () => {
|
||||||
|
const relPath = ".fusion/agents/agent-legacy/HEARTBEAT.md";
|
||||||
|
await mkdir(join(testDir, ".fusion", "agents", "agent-legacy"), { recursive: true });
|
||||||
|
await writeFile(join(testDir, relPath), "Legacy heartbeat", "utf-8");
|
||||||
|
|
||||||
|
const content = await resolveAgentHeartbeatProcedure(
|
||||||
|
makeAgent({ id: "agent-legacy", heartbeatProcedurePath: relPath }),
|
||||||
|
testDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content).toBe("Legacy heartbeat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects traversal heartbeat procedure paths", async () => {
|
||||||
|
const content = await resolveAgentHeartbeatProcedure(
|
||||||
|
makeAgent({ heartbeatProcedurePath: "../outside.md" }),
|
||||||
|
testDir,
|
||||||
|
);
|
||||||
|
expect(content).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds default heartbeat procedure only for valid project-relative markdown paths", async () => {
|
||||||
|
const seeded = await ensureDefaultHeartbeatProcedureFile(
|
||||||
|
testDir,
|
||||||
|
".fusion/agents/ceo-agent2736/HEARTBEAT.md",
|
||||||
|
"Default",
|
||||||
|
);
|
||||||
|
expect(seeded).toBeTruthy();
|
||||||
|
|
||||||
|
const invalid = await ensureDefaultHeartbeatProcedureFile(testDir, "../outside.md", "Default");
|
||||||
|
expect(invalid).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user