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
|
||||
|
||||
- **Upgrade to Default Heartbeat Procedure** still sets `heartbeatProcedurePath` to:
|
||||
- `.fusion/agents/{agent.id}/HEARTBEAT.md`
|
||||
- If the default file does not exist yet, the backend seeds it from the built-in template.
|
||||
- Canonical per-agent asset directories now use **display name + immutable id suffix** (example: `ceo-agent2736`).
|
||||
- Canonical heartbeat path example: `.fusion/agents/ceo-agent2736/HEARTBEAT.md`
|
||||
- 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.
|
||||
|
||||
## New Agent Presets (Dashboard UI)
|
||||
@@ -245,7 +248,7 @@ The custom tab exposes separate fields for:
|
||||
|
||||
- **Title** (`title`) — optional role title/description
|
||||
- **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
|
||||
- **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 { tmpdir } from "node:os";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import {
|
||||
getCanonicalAgentInstructionsBundleDirName,
|
||||
getLegacyAgentInstructionsBundleDirName,
|
||||
getSafeAgentAssetIdSegment,
|
||||
} from "../types.js";
|
||||
|
||||
describe("AgentStore — instructions bundle", () => {
|
||||
let testDir: string;
|
||||
@@ -62,7 +67,9 @@ describe("AgentStore — instructions bundle", () => {
|
||||
it("getInstructionsDir returns the managed bundle directory path", async () => {
|
||||
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
|
||||
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 () => {
|
||||
@@ -273,4 +280,27 @@ describe("AgentStore — instructions bundle", () => {
|
||||
expect(migrated.instructionsText).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 { tmpdir } from "node:os";
|
||||
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 {
|
||||
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
||||
@@ -150,6 +155,27 @@ describe("AgentStore", () => {
|
||||
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 () => {
|
||||
const agent = await store.createAgent({
|
||||
name: "With Meta",
|
||||
|
||||
@@ -38,7 +38,19 @@ import type {
|
||||
Task,
|
||||
AgentLogEntry,
|
||||
} 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 { TaskStore } from "./store.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
@@ -262,7 +274,7 @@ export class AgentStore extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
|
||||
const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent);
|
||||
const newAbsPath = join(this.rootDir, "..", newRelPath);
|
||||
|
||||
// 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.
|
||||
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
|
||||
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
|
||||
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
@@ -742,7 +754,9 @@ export class AgentStore extends EventEmitter {
|
||||
* Does not create the directory.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
async listBundleFiles(agentId: string): Promise<string[]> {
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
|
||||
|
||||
try {
|
||||
const entries = await readdir(bundleDir, { withFileTypes: true });
|
||||
@@ -771,7 +785,8 @@ export class AgentStore extends EventEmitter {
|
||||
*/
|
||||
async readBundleFile(agentId: string, filePath: string): Promise<string> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -782,7 +797,7 @@ export class AgentStore extends EventEmitter {
|
||||
return this.withLock(agentId, async () => {
|
||||
this.validateBundleFilePath(filePath);
|
||||
|
||||
const bundleDir = this.getBundleDir(agentId);
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agentId, true);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const existingFiles = await this.listBundleFiles(agentId);
|
||||
@@ -804,7 +819,8 @@ export class AgentStore extends EventEmitter {
|
||||
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
|
||||
return this.withLock(agentId, async () => {
|
||||
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 });
|
||||
|
||||
if (normalizedConfig.mode === "managed") {
|
||||
await mkdir(this.getBundleDir(agentId), { recursive: true });
|
||||
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
|
||||
@@ -2057,8 +2073,108 @@ export class AgentStore extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBundleDir(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-instructions`);
|
||||
private getCanonicalBundleDir(agent: Agent): string {
|
||||
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 {
|
||||
|
||||
@@ -3263,20 +3263,71 @@ export interface AgentConfigRevision {
|
||||
*/
|
||||
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
|
||||
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
|
||||
* 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
|
||||
* lives alongside any other future per-agent assets and survives agent
|
||||
* renames (which do not change the immutable agent id).
|
||||
* The path is laid out under `.fusion/agents/<canonical-agent-dir>/HEARTBEAT.md`.
|
||||
*/
|
||||
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
|
||||
export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: string): string {
|
||||
if (!agentId || typeof agentId !== "string") {
|
||||
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 */
|
||||
|
||||
@@ -2500,8 +2500,21 @@ function HeartbeatProcedureSection({
|
||||
const [fileLoadError, setFileLoadError] = useState<string | null>(null);
|
||||
const [justSavedFile, setJustSavedFile] = useState(false);
|
||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||
const expectedDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
|
||||
const onDefault = currentPath === expectedDefaultPath;
|
||||
const canonicalDefaultPath = `.fusion/agents/${agent.name
|
||||
.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 loadHeartbeatFile = useCallback(async (path: string) => {
|
||||
@@ -2575,9 +2588,10 @@ function HeartbeatProcedureSection({
|
||||
<div className="config-section">
|
||||
<h3>Heartbeat Procedure</h3>
|
||||
<p className="config-description">
|
||||
The per-tick procedure this agent runs every wake. Defaults to a project-level
|
||||
markdown file you can edit. Resets on every tick — no need to restart the agent
|
||||
after editing.
|
||||
The per-tick procedure this agent runs every wake. Defaults to a per-agent
|
||||
markdown file (for example <code>.fusion/agents/ceo-agent2736/HEARTBEAT.md</code>)
|
||||
that you can edit. Legacy id-only default paths remain valid. Resets on every tick —
|
||||
no need to restart the agent after editing.
|
||||
</p>
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
@@ -2629,7 +2643,7 @@ function HeartbeatProcedureSection({
|
||||
</button>
|
||||
<span className="config-hint">
|
||||
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.
|
||||
Each agent gets its own per-agent file, so edits stay scoped to this agent.
|
||||
Operator edits to the file are preserved.
|
||||
|
||||
@@ -599,12 +599,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
id="agent-heartbeat-procedure-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md"
|
||||
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||
value={heartbeatProcedurePath}
|
||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
@@ -742,7 +742,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
id="agent-review-heartbeat-procedure-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md"
|
||||
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||
value={heartbeatProcedurePath}
|
||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ type AgentRecord = {
|
||||
updatedAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
reportsTo?: string;
|
||||
heartbeatProcedurePath?: string;
|
||||
};
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -51,8 +52,11 @@ vi.mock("@fusion/core", () => {
|
||||
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md",
|
||||
getDefaultHeartbeatProcedurePath: (agentId: string) =>
|
||||
`.fusion/agents/${agentId}/HEARTBEAT.md`,
|
||||
getDefaultHeartbeatProcedurePath: (agentId: string, agentName?: string) =>
|
||||
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",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: input.metadata ?? {},
|
||||
heartbeatProcedurePath: input.heartbeatProcedurePath,
|
||||
};
|
||||
agents.set(id, 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", () => {
|
||||
it("updates agent skills", async () => {
|
||||
agents.set("agent-001", {
|
||||
|
||||
@@ -10,6 +10,21 @@ interface AgentCoreRouteDeps {
|
||||
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 {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
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
|
||||
// non-ephemeral agents when no override is provided). Idempotent —
|
||||
// operator edits are kept.
|
||||
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id);
|
||||
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
|
||||
if (agent.heartbeatProcedurePath === expectedDefaultPath) {
|
||||
try {
|
||||
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`);
|
||||
}
|
||||
|
||||
const targetPath = getDefaultHeartbeatProcedurePath(req.params.id);
|
||||
const targetPath = isCompatibleDefaultHeartbeatPath(existing.heartbeatProcedurePath, existing)
|
||||
? existing.heartbeatProcedurePath!
|
||||
: getDefaultHeartbeatProcedurePath(existing.id, existing.name);
|
||||
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
||||
scopedStore.getRootDir(),
|
||||
targetPath,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
resolveAgentInstructionsWithRatings,
|
||||
buildAgentChatPrompt,
|
||||
buildSystemPromptWithInstructions,
|
||||
ensureDefaultHeartbeatProcedureFile,
|
||||
resolveAgentHeartbeatProcedure,
|
||||
} from "../agent-instructions.js";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
@@ -700,3 +702,61 @@ describe("diagnostics logging", () => {
|
||||
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