feat(FN-2962): merge fusion/fn-2962

- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support

Commits merged:
- feat(FN-2962): complete Step 8 — add changeset and documentation

Files changed:
.changeset/register-custom-providers.md | 5 +++++
 1 file changed, 5 insertions(+)

Fusion-Task-Id: FN-2962
This commit is contained in:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 6c051b1851
commit 64b5f677ff
33 changed files with 1048 additions and 60 deletions

View File

@@ -13,6 +13,7 @@ import {
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
@@ -1655,6 +1656,10 @@ describe("HeartbeatMonitor", () => {
// Should NOT include task-specific content
expect(executionPrompt).not.toContain("Assigned task:");
expect(executionPrompt).not.toContain("Task description:");
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason:");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
@@ -2123,6 +2128,60 @@ describe("HeartbeatMonitor", () => {
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
// the agent re-runs its procedure each tick instead of grinding on the
// assigned task (paperclip-parity).
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason: message_received");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
try {
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "MY-PROCEDURE.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Custom procedure should appear; default constant should not.
expect(executionPrompt).toContain("## Custom CEO Procedure");
expect(executionPrompt).toContain("1. Review reports");
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
// Wake Delta still rendered.
expect(executionPrompt).toContain("## Wake Delta");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "../escape.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Invalid path → fall back to the default constant.
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("does not include message section when no unread messages", async () => {

View File

@@ -21,6 +21,7 @@ const reloadMock = vi.fn(async () => {});
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
const existsSyncMock = vi.fn((_path: PathLike) => false);
const readFileSyncMock = vi.fn((_path?: any) => "{}");
const readCustomProvidersMock = vi.fn(() => []);
// Route async `exec` through the `execSync` mock so the promisify bridge works.
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
@@ -68,6 +69,10 @@ vi.mock("node:fs", async () => {
};
});
vi.mock("../custom-providers.js", () => ({
readCustomProviders: readCustomProvidersMock,
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
create: () => ({
@@ -382,6 +387,7 @@ describe("createFnAgent", () => {
execSyncMock.mockReturnValue("");
existsSyncMock.mockReturnValue(false);
readFileSyncMock.mockReturnValue("{}");
readCustomProvidersMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createAgentSessionMock.mockResolvedValue({
session: {
@@ -484,6 +490,36 @@ describe("createFnAgent", () => {
expect(refreshMock).toHaveBeenCalled();
});
it("registers custom providers from global settings", async () => {
readCustomProvidersMock.mockReturnValue([
{
id: "custom-openai",
name: "Custom OpenAI",
apiType: "openai-compatible",
baseUrl: "https://custom.example/v1",
apiKey: "CUSTOM_API_KEY",
models: [{ id: "custom-model", name: "Custom Model" }],
},
] as any);
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
});
expect(registerProviderMock).toHaveBeenCalledWith("custom-openai", expect.objectContaining({
baseUrl: "https://custom.example/v1",
api: "openai-completions",
apiKey: "CUSTOM_API_KEY",
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
}));
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
const { createFnAgent } = await import("../pi.js");

View File

@@ -23,7 +23,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
import { heartbeatLog, formatError } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
import { promptWithFallback } from "./pi.js";
@@ -285,6 +285,37 @@ When sending messages:
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
/**
* Per-tick heartbeat procedure appended to every execution prompt. Forces the
* agent to re-anchor on its own operating procedure each wake instead of
* silently grinding on a previously assigned task.
*/
export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
1. **Identity & context** — review your soul, instructions, and memory (already
loaded in the system prompt). Confirm who you are and what you're responsible
for before continuing prior work.
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
messages first; reply with reply_to_message_id when answering.
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else.
4. **Assignment review** — if you have an assigned task, re-read its current
description, latest comments, and any task documents. Decide whether the
prior plan is still valid given the wake delta. Do not assume yesterday's
plan is still correct.
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
advance the task, create a follow-up, log findings, delegate, or update
memory. Don't stop at planning unless the task is a planning task.
6. **Persist progress** — fn_task_log for observations, fn_task_document_write
for durable findings, status updates only when the work warrants it.
7. **Exit** — call fn_heartbeat_done with a one-line summary of what changed
this tick. If you took no action, say so and explain why.
Critical: a heartbeat without observable progress (a log, a document write, a
status change, a comment, a delegation, or an explicit "no-op with reason") is
a bug. Do not loop on the same plan across heartbeats without recording why.`;
/** Parameter schema for the fn_heartbeat_done tool */
const heartbeatDoneParams = Type.Object({
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
@@ -1337,6 +1368,30 @@ export class HeartbeatMonitor {
let pendingMessages: Message[] = [];
let executionPrompt: string;
// Derive a stable wake reason from source, triggerDetail, and trigger
// type so the agent can change its strategy based on *why* it woke up.
// Mirrors paperclip's PAPERCLIP_WAKE_REASON (see plan: wake delta).
const deriveWakeReason = (): string => {
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
if (triggerDetail === "wake-on-message") return "message_received";
if (triggerDetail === "wake-on-comment") return "comment_mention";
if (triggerDetail === "task-assigned") return "task_assigned";
if (source === "timer") return "timer";
if (source === "assignment") return "task_assigned";
if (source === "automation") return "automation";
if (source === "routine") return "routine";
return triggerDetail || source;
};
const wakeReason = deriveWakeReason();
// Per-agent override of the default HEARTBEAT_PROCEDURE: if the agent
// configured a heartbeatProcedurePath pointing to a markdown file in
// the project, use that instead. Reloaded fresh each tick (matches the
// existing instructionsPath/instructionsText reload contract) so an
// operator can iterate on procedure text without restarting agents.
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
const heartbeatProcedureText = customProcedure ?? HEARTBEAT_PROCEDURE;
if (isNoTaskRun) {
// No-task heartbeat: agent has identity but no assigned task
// Fetch unread messages when messageStore is available (for all trigger types)
@@ -1365,6 +1420,16 @@ export class HeartbeatMonitor {
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
`- assigned task: none`,
`- pending messages: ${pendingMessages.length}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"Run the Heartbeat Procedure (below) before doing anything else — even a",
"timer-only wake should re-check messages, memory, and project state.",
"",
"**No assigned task** — This heartbeat run has no task assignment.",
"",
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
@@ -1388,6 +1453,9 @@ export class HeartbeatMonitor {
"",
"Your soul, instructions, and memory are already loaded in the system prompt.",
"Focus on work that benefits the project without requiring a specific task context.",
"",
heartbeatProcedureText,
"",
"Call fn_heartbeat_done when finished.",
].join("\n");
} else {
@@ -1450,6 +1518,18 @@ export class HeartbeatMonitor {
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`Assigned task: ${taskId}${taskTitle}`,
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
`- assigned task: ${taskId}`,
`- pending messages: ${pendingMessages.length}`,
`- triggering comments: ${effectiveTriggeringCommentIds?.length ?? 0}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"Before resuming prior task work, run the Heartbeat Procedure (below) and",
"decide what action this delta requires. Your assigned task is one input",
"to the procedure — not the only thing to consider.",
"",
"Task description:",
taskDetail!.description,
"",
@@ -1457,7 +1537,9 @@ export class HeartbeatMonitor {
...triggeringCommentLines,
...pendingMessagesLines,
"",
"Review the task status and take appropriate action. Call fn_heartbeat_done when finished.",
heartbeatProcedureText,
"",
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
].join("\n");
}

View File

@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
import {
readProjectMemory,
type Agent,
@@ -32,7 +33,12 @@ function isPathTraversal(path: string): boolean {
return path.split(/[\\/]+/).includes("..");
}
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
function resolveValidatedMarkdownPath(
rawPath: string,
rootDir: string,
agentId: string,
fieldLabel: string,
): string | null {
const trimmed = rawPath.trim();
if (!trimmed) {
return null;
@@ -40,37 +46,118 @@ function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agen
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
log.warn(
`instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
`${fieldLabel} too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
);
return null;
}
if (!trimmed.toLowerCase().endsWith(".md")) {
log.warn(`instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} must end in .md for agent ${agentId}: ${trimmed}`);
return null;
}
if (isAbsolute(trimmed)) {
log.warn(`instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} must be project-relative for agent ${agentId}: ${trimmed}`);
return null;
}
const normalized = normalize(trimmed);
if (isPathTraversal(normalized)) {
log.warn(`instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} traversal is not allowed for agent ${agentId}: ${trimmed}`);
return null;
}
const resolvedPath = resolve(rootDir, normalized);
const rel = relative(rootDir, resolvedPath);
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
log.warn(`instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} escapes project root for agent ${agentId}: ${trimmed}`);
return null;
}
return resolvedPath;
}
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
return resolveValidatedMarkdownPath(rawPath, rootDir, agentId, "instructionsPath");
}
/**
* Load a per-agent heartbeat procedure markdown file. Returns the file
* contents (trimmed/clamped to MAX_INSTRUCTIONS_TEXT_LENGTH), or null if no
* path is configured, the path is invalid, or the file is unreadable. Caller
* substitutes a default constant on null.
*/
export async function resolveAgentHeartbeatProcedure(
agent: Agent | null | undefined,
rootDir: string,
): Promise<string | null> {
const rawPath = agent?.heartbeatProcedurePath?.trim();
if (!agent || !rawPath) {
return null;
}
const filePath = resolveValidatedMarkdownPath(rawPath, rootDir, agent.id, "heartbeatProcedurePath");
if (!filePath) {
return null;
}
try {
const content = await readFile(filePath, "utf-8");
const normalized = trimAndClamp(
content,
MAX_INSTRUCTIONS_TEXT_LENGTH,
"heartbeat procedure file content",
agent.id,
);
return normalized || null;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
log.warn(`Heartbeat procedure file not found for agent ${agent.id}: ${filePath}`);
} else {
log.warn(`Failed to read heartbeat procedure file for agent ${agent.id}: ${filePath} (${code})`);
}
return null;
}
}
/**
* Write the default heartbeat procedure file at `pathRel` (project-relative)
* if it doesn't already exist. Idempotent: leaves an existing file untouched
* so operators can edit the procedure without losing their changes on the
* next upgrade run. Returns the absolute path that was ensured (or null if
* the path is invalid).
*
* The default content is supplied by the caller — kept as a parameter rather
* than imported from agent-heartbeat.ts to avoid a circular dependency
* (agent-heartbeat.ts already imports from this module).
*/
export async function ensureDefaultHeartbeatProcedureFile(
rootDir: string,
procedurePathRel: string,
defaultContent: string,
): Promise<string | null> {
// Reuse the same path validation as the per-agent loader so we never write
// outside the project root.
const filePath = resolveValidatedMarkdownPath(procedurePathRel, rootDir, "system", "heartbeatProcedurePath");
if (!filePath) {
return null;
}
try {
await access(filePath, fsConstants.F_OK);
return filePath; // Already exists — preserve operator edits.
} catch {
// Falls through to write below.
}
try {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, defaultContent, "utf-8");
log.log(`Seeded default heartbeat procedure file at ${filePath}`);
return filePath;
} catch (err: unknown) {
log.warn(`Failed to seed default heartbeat procedure file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
function getTrendLabel(trend: AgentRatingSummary["trend"]): string {
switch (trend) {
case "improving":

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { CustomProvider } from "@fusion/core";
export function readCustomProviders(): CustomProvider[] {
try {
const settingsPath = join(homedir(), ".fusion", "settings.json");
const raw = readFileSync(settingsPath, "utf-8");
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
} catch {
return [];
}
}

View File

@@ -45,7 +45,10 @@ export {
resolveAgentInstructionsWithRatings,
resolveAgentInstructions,
buildSystemPromptWithInstructions,
resolveAgentHeartbeatProcedure,
ensureDefaultHeartbeatProcedureFile,
} from "./agent-instructions.js";
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";

View File

@@ -43,6 +43,7 @@ import {
import { isContextLimitError } from "./context-limit-detector.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { piLog, extensionsLog } from "./logger.js";
import { readCustomProviders } from "./custom-providers.js";
export interface AgentResult {
session: AgentSession;
@@ -998,6 +999,35 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
await registerExtensionProviders(options.cwd, modelRegistry);
for (const provider of readCustomProviders()) {
try {
modelRegistry.registerProvider(provider.id, {
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
})),
});
piLog.log(`Registered custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
piLog.warn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
// Build the pi built-in tool set. We deliberately do NOT use the bundled
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
// tools that pi-claude-cli's Claude→pi name mapping depends on (Glob→find,